Можно долго изучать Angular, оттачивая навыки работы с разными аспектами фреймворка, разбирая паттерны и лучшие практики. Но в конце концов, когда вы станете мастером своего дела, всегда останется умение, которое можно улучшать бесконечно: способность декомпозировать сложные задачи на удобоваримые куски. Это одно из самых важных качеств хорошего архитектора. Если вы чувствуете уверенность в своих знаниях сеньора, я советую вам направить усилия в этом направлении для дальнейшего роста.

В статье выберем сложную задачу и разберемся, как подойти к ней учитывая будущую поддержку и расширение возможностей. Я разрабатываю библиотеку компонентов Taiga UI много лет и успел отхватить свою долю ошибок, инсайтов и полезного опыта. Я думаю, хорошим примером для изучения могут послужить выпадашки: всплывающие элементы, такие как дропдауны или подсказки.

«Дайте мне шесть часов, чтобы срубить дерево, и я потрачу первые четыре на заточку топора». Авраам Линкольн

Пока мы все терпеливо ждем Popover API в браузерах, которые нам надо поддерживать, придется взять все аспекты задачи на себя. Воспользуемся паттерном порталов — популярным подходом для реализации всплывающих элементов, который избавит нас от проблем с появлением скроллбаров, обрезанием контента из-за overflow: hidden и войны z-index. Я писал, что в Taiga UI мы используем именно его:

Создадим гибкую инфраструктуру для выпадашек в Angular-приложении. Первым делом разобьем задачу на основные подзадачи, которые нам надо решить:

  1. Что показывать.

  2. Где показывать.

  3. Когда показывать.

Все вопросы независимые, и это хороший знак. Декомпозиция заключается в том, чтобы понять, какие части решения могут работать в изоляции и не влиять на остальные. Это позволит нам писать небольшие легко поддерживаемые куски кода. Пойдем по списку и разберемся, зачем нужен гибкий подход в каждом из этих пунктов.

Что показывать

В Angular можно выводить контент в виде интерполяции, шаблонов и динамических компонентов. Наш вопрос не в этом. У выпадашек есть определенный дизайн вокруг их содержимого, нужно определить именно его. Содержимое будем передавать с помощью библиотеки Polymorpheus. По сути, это универсальная структурная директива, в которую можно передавать все и не переживать за ngTemplateOutlet, ngComponentOutlet и так далее.

Вопрос «Что показывать» нужно решать гибко, потому что иногда наши всплывающие элементы выглядят по-разному, в зависимости от контекста. Но это не значит, что нужно полностью переделывать всю остальную инфраструктуру. К примеру, выпадающий список может выглядеть иначе на мобильном устройстве.

Select компонент на компьютере и на телефоне
Select компонент на компьютере и на телефоне

Мы можем достичь разного внешнего вида выпадашек через Dependency Injection, не переделывая остальную инфраструктуру. В конечном счете выпадашка — это динамически создаваемый элемент. Передадим его через DI-токен и сможем иметь поведение по умолчанию, а с помощью директив переопределять его, как в случае с LineClamp. 

LineClamp использует CSS line-clamp, чтобы подрезать текст до определенного числа строк, и показывает весь контент при наведении. В отличие от обычной подсказки, у него нет стрелочки, указывающей на хост.

Обычная подсказка и LineClamp
Обычная подсказка и LineClamp

Это было легко. Теперь давайте подумаем над позиционированием.

Где показывать

Определить положение — задача непростая. Думаю, что непосредственно в строчках кода она займет больше всего в практической реализации. В этой статье я не буду углубляться в расчет координат. По сути, это несложная арифметика, построенная на инструментах, о которых я рассказываю тут.

Для начала — у каждого всплывающего элемента есть некий хост. Это может быть иконка подсказки, кнопка с выпадающим списком или даже выделенный текст для контекстного меню или сам курсор мыши.

Всплывающие элементы находятся рядом с хостом
Всплывающие элементы находятся рядом с хостом

Для определения позиции нам нужно знать, где находится хост и какой он формы. Для этого создадим первую абстрактную сущность — RectAccessor. Все, для чего он нужен, это предоставление DOMRect объекта хоста по запросу.

Далее, в зависимости от имплементации, нам потребуется второй абстрактный класс — PositionAccessor. Он позволит передать DOMRect самого всплывающего элемента и получить координаты для его показа, основываясь на RectAccessor. К примеру, LineClamp показывает контент прямо над хостом, подсказки появляются рядом — со стрелкой, указывающей на середину хоста, а выпадашки выводятся на определенном расстоянии снизу или сверху.

Наши компоненты будут работать с этими абстрактными сущностями, а директивы будут предоставлять реализации для конкретных задач.

Когда показывать

Осталось продумать механизм показа и скрытия элементов. У нас могут быть самые разные триггеры. Например, мы можем показывать подсказки по наведению курсора или фокусу с клавиатуры, показывать выпадашки по клику, контекстные меню по правому клику, можем выводить их вручную в зависимости от условия. Для достижения нужной нам свободы введем термин Driver

По сути, Driver — это роль, которую выполняет Observable, отвечающий за показ нашей сущности. Ее, в свою очередь, мы можем назвать Vehicle. Как и ранее, директивы будут предоставлять имплементацию. Будет хорошей идеей работать с мультитокенами, поскольку может потребоваться расширять уже существующие механизмы. Обычно выпадашки мы открываем по клику, но к какой-то определенной хотим добавить раскрытие по наведению мыши.

Контекстное меню по правой кнопке мыши
Контекстное меню по правой кнопке мыши

RxJS прекрасно подходит для этой цели, поскольку по своей сути это управление событиями. Мы можем совмещать несколько fromEvent-потоков в один общий результат. Например, собрать стрим для открытия выпадашки по клику, нажатию стрелки вниз и событию pointerenter — и закрытия по клавише Escape или клику вне хоста и самой выпадашки. 

Сейчас Angular отдаляется от RxJS, стараясь сделать его необязательным знанием для разработчиков. А я все еще советую вам потратить немного времени и освоиться с этим инструментом. Не только потому что он очень и очень полезен, но и потому что он скоро доберется до нативной поддержки в браузере. Вы можете посмотреть мой репозиторий с небольшими задачками, чтобы начать лучше управляться с RxJS.

Переходим к практике

Мы создаем инфраструктуру для всплывающих элементов. Решили пойти по пути порталов и создавать динамические компоненты поверх контента приложения. У нас есть InjectionToken для компонента, который мы создаем, и директивы умеют его переопределять. 

В зависимости от наших нужд есть директивы, помогающие определить положение элемента, предоставляя нам DOMRect хоста и алгоритм, высчитывающий координаты. Для управления появлением и скрытием у нас есть еще одна группа директив. Мы можем заложить поведение по умолчанию в саму директиву подсказки или выпадашки и обращаться к нему, если иное поведение не было предоставлено.

Посмотрим на пару примеров из Taiga UI:

<button
  tuiDropdown="Great Scott!" ← базовая директива с контентом
  tuiDropdownOpen ← драйвер открытия по клику и стрелкам
  tuiDropdownHover ← драйвер открытия по наведению мыши
>
  This is heavy!
</button>

<div
  tuiHint="Wow! How exciting!" ← базовая директива с контентом
  tuiHintPointer ← драйвер и RectAccessor, следящий за курсором
>
  In this block hint follows cursor
</div>

Поскольку DI иерархичен, нам не нужен доступ к самой директиве, чтобы переопределить токен компонента. Мы можем сделать это выше по дереву:

<tui-select
  tuiDropdownMobile="Select user" ← кастомный компонент
  [(ngModel)]="user"
>
  Select user
  <tui-data-list-wrapper *tuiDataList [items]="users" />
</tui-select>

Посмотрим на сам код типовой выпадашки. Разумеется, в реальной жизни нам потребуется обработать больше нюансов, но этих фрагментов хватит, чтобы понять общую картину.

Для начала определимся с компонентом для показа. Для этого создадим токен со значением по умолчанию:

export const DROPDOWN_COMPONENT = new InjectionToken('', {
  factory: () => DropdownComponent,
});

В анимации выше мы видели мобильную шторку, можно предоставить ее через директиву:

@Directive({
  // ...
  providers: [
    {
      provide: DROPDOWN_COMPONENT,
      useFactory: () =>
        isMobile(inject(DOCUMENT).defaultView.navigator.userAgent)
          ? DropdownMobileComponent
          : inject(DROPDOWN_COMPONENT, {skipSelf: true}),
    },
  ],
})
export class DropdownMobileDirective {}

В ней мы получим DOCUMENT и проверим userAgent. Если мы на мобильном устройстве — подложим в токен специальный компонент. Иначе вернем значение выше по иерархии.

Теперь нужно определиться с позиционированием. Как я говорил, мы не будем писать сам код вычислений, так что представим, что у нас есть PositionDirective для выполнения расчетов. Ей потребуется RectAccessor:

export class RectAccessor {
  private readonly element = inject(ElementRef).nativeElement;

  // Обязательный метод RectAccessor
  public getRect(): DOMRect {
    return this.element.getBoundingClientRect();
  }
}

Еще нам понадобится директива, чтобы собрать все драйверы вместе и связать их с выпадашкой:

export class DriverDirective {
  private readonly vehicle = inject(Vehicle);

  private readonly sub = merge(...inject(Driver))
    .pipe(distinctUntilChanged(), takeUntilDestroyed())
    .subscribe(value => this.vehicle.toggle(value));
}

Директива выпадашки предоставляет себя в качестве Vehicle:

@Directive({
  standalone: true,
  selector: '[dropdown]',
  providers: [{ provide: Vehicle, useExisting: DropdownDirective }],
  hostDirectives: [DriverDirective, RectAccessor, PositionDirective],
})
export class DropdownDirective {
  // Для создания компонента
  private readonly service = inject(DropdownService); 
  private readonly component = inject(DROPDOWN_COMPONENT);
    
  public dropdown = input(''); // строка, шаблон, компонент

  // Обязательный метод Vehicle
  public toggle(show: boolean): void {
    this.service.toggle(this.component, show);
  }
}

DROPDOWN_COMPONENT возьмет из DI PositionDirective и будет обращаться к ней, чтобы получить свои координаты. А DropdownService — всего лишь прослойка для создания и уничтожения динамических компонентов в виде порталов где-то в DOM, где мы хотим их разместить. Осталось создать драйвер для показа и скрытия. Для примера возьмем самую базовую реализацию, управляемую инпутом снаружи:

@Directive({
  // ...
  providers: [{ provide: Driver, useExisting: DropdownManual, multi: true }],
})
export class DropdownManual extends Observable<boolean> {
  public open = input(false);

  constructor() {
    super(subscriber => toObservable(this.open).subscribe(subscriber));
  }
}

Более сложные драйверы, такие как наведение мыши или контекстные меню, клавиатура, — все следуют той же логике. Мы собираем поток и предоставляем его как Driver

Мой главный совет

Angular — прекрасно продуманный фреймворк. Он насаждает хорошие архитектурные практики за счет композиции директив, внедрения зависимостей, сервисов и hostDirectives. В вашем распоряжении все что нужно для расширяемых, легко поддерживаемых решений. Когда заказчик приходит с новыми запросами, я уверен, что они не потребуют полного переписывания кода. Потому что, когда в Angular код написан правильно, он не просто красив — он еще имеет крепкий фундамент, на базе которого можно строить все более сложные конструкции.

Мой главный совет: прежде чем писать любой код, хорошенько подумайте, должен ли он быть написан. Лучше потратить время в начале пути и тщательно продумать результат. Мыслите за рамками конкретных спецификаций, старайтесь создать инфраструктуру, решающую задачу на более общем уровне. Вы все равно не сможете продумать все крайние кейсы, так что будьте гибче. Все инструменты у вас под рукой, научитесь пользоваться ими — и вы станете прекрасным дополнением к любой команде и будете писать код с удовольствием. В конце концов, мы пошли в инженеры, потому что любим решать задачи, и Angular для этого эффективен и эргономичен.

Комментарии (1)


  1. fafnur
    29.05.2024 09:37
    +2

    Хорошая статья для тех, кто создает свой UI KIT на Angular. Однако, достаточно сложный пример для разработчиков вне Angular.