Паттерн порталов в Angular: для чего нужен root-компонент в Taiga UI

в 10:27, , рубрики: angular, css, dom, dropdown, html, javascript, modal, open source, Portal, taiga, TypeScript, z-index, Блог компании Tinkoff, Разработка веб-сайтов

Мой коллега Роман недавно объявил о выходе нашей новой библиотеки компонентов под Angular Taiga UI. В инструкциях Getting started сказано, что приложение нужно обернуть в некий tui-root. Давайте разберемся, что он делает, узнаем, как и зачем мы используем порталы и что это вообще такое.

Паттерн порталов в Angular: для чего нужен root-компонент в Taiga UI - 1

Что такое портал?

Представьте себе компонент select. У него есть выпадашка с вариантами на выбор. Если хранить ее в том же месте в DOM, что и сам компонент, можно нарваться на ряд проблем. Нижестоящие элементы могут выскочить поверх, а контейнер — срезать содержимое:

Паттерн порталов в Angular: для чего нужен root-компонент в Taiga UI - 2

Проблемы глубины обычно решаются через z-index, что запускает Войну Миров Z в вашем приложении. Часто можно встретить значения 100, 10000, 10001. Но даже если суметь это грамотно разрулить, от overflow: hidden все равно не убежишь. Что же делать?

Вместо размещения выпадашки рядом с хостом мы поместим ее в специальный контейнер поверх всего приложения. Тогда остальные элементы будут находиться в своем изолированном контексте и проблемы с z-index отпадут. Этот контейнер и есть «портал». Root-компонент в Taiga UI нужен как раз для создания подобных порталов. Рассмотрим его шаблон:

<tui-scroll-controls></tui-scroll-controls>
<tui-portal-host>
    <div class="content"><ng-content></ng-content></div>
    <tui-dialog-host></tui-dialog-host>
    <ng-content select="tuiOverDialogs"></ng-content>
    <tui-notifications-host></tui-notifications-host>
    <ng-content select="tuiOverNotifications"></ng-content>
</tui-portal-host>
<ng-content select="tuiOverPortals"></ng-content>
<tui-hints-host></tui-hints-host>
<ng-content select="tuiOverHints"></ng-content>

Общие и специализированные порталы

И tui-dialog-host, и tui-portal-host по сути своей — порталы. Но работают они по-разному. Для начала взглянем на второй. В Taiga UI он используется для показа выпадашек. Но это портал общего назначения. Он управляется очень простым сервисом:

@Injectable({
   providedIn: 'root',
})
export class TuiPortalService {
   private host: TuiPortalHostComponent;

   add<C>(componentFactory: ComponentFactory<C>, injector: Injector): ComponentRef<C> {
       return this.host.addComponentChild(componentFactory, injector);
   }

   remove<C>({hostView}: ComponentRef<C>) {
       hostView.destroy();
   }

   addTemplate<C>(templateRef: TemplateRef<C>, context?: C): EmbeddedViewRef<C> {
       return this.host.addTemplateChild(templateRef, context);
   }

   removeTemplate<C>(viewRef: EmbeddedViewRef<C>) {
       viewRef.destroy();
   }
}

Сам компонент тоже незамысловат. Всё, что он делает, — выводит шаблоны и динамические компоненты поверх приложения. Это значит, что позиционирование, закрытие и вся иная логика лежит на плечах самих выводимых элементов.

Такой универсальный портал полезен, если потребуется что-то особенное. Например, кнопка «Наверх», всегда видимая над контентом.

Выпадашки

Создавая решение для выпадающих элементов, нужно задуматься над позиционированием. Тут у нас несколько вариантов:

  1. Позиционировать единожды и блокировать скролл. Так работает Material по умолчанию.

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

  3. Следить за положением хоста.

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

Ну и есть еще это: 

Паттерн порталов в Angular: для чего нужен root-компонент в Taiga UI - 3

Если хост покидает видимую зону — нужно закрыть выпадашку. Этим занимается сервис Obscured. Он следит за хостом и закрывает выпадающий элемент при полном перекрытии.

Диалоги

Чтобы разобраться со специализированными порталами, взглянем на диалоги. Нотификации и подсказки работают так же, но на примере модальных окон можно обсудить еще пару интересных вопросов.

Вот так выглядит компонент с диалогами:

<section
   *ngFor="let item of dialogs$ | async"
   polymorpheus-outlet
   tuiFocusTrap
   tuiOverscroll="all"
   class="dialog"
   role="dialog"
   aria-modal="true"
   [attr.aria-labelledby]="item.id"
   [content]="item.component"
   [context]="item"
   [@tuiParentAnimation]
></section>
<div class="overlay"></div>

Как видите, в нем есть цикл ngFor, перебирающий конкретные элементы. Это позволяет заложить определенную логику, типа захвата фокуса или блокировки скролла. Также тут используется хитрое внедрение зависимостей, позволяющее диалогам не зависеть от модели данных и конкретного внешнего вида. Хост собирает потоки с диалогами с помощью специального мультитокена, объединяет их в один и выводит результат.

Таким образом, в одном приложении могут уживаться несколько видов диалогов. В Taiga UI заложено два дизайна всплывающих окон — базовый и имитирующий нативные алерты на мобильных устройствах. Последний выглядит по-разному на iOS и Android. Вы можете легко добавить свою реализацию. Давайте посмотрим, как это сделать.

Сервис показа модальных окон возвращает Observable. При подписке на него окно выводится, при отписке — закрывается. В этот поток диалог также может передавать данные. Для начала создадим свой компонент диалога. Всё, что тут важно, он может брать из DI-токена POLYMORPHEUS_CONTEXT. В этом объекте будет поле content с содержимым модалки и observer конкретного инстанса диалога. Его можно завершить через complete, что закроет диалог, и можно вернуть данные с помощью метода next. Кроме того, в этом объекте будут содержаться все параметры, которые вы пожелаете добавить к вашей реализации диалогов. Сервис для их показа наследуется от абстрактного класса:

const DIALOG = new PolymorpheusComponent(MyDialogComponent);
const DEFAULT_OPTIONS: MyDialogOptions = {
   label: '',
   size: 's',
};

@Injectable({
   providedIn: 'root',
})
export class MyDialogService extends AbstractTuiDialogService<MyDialogOptions> {
   protected readonly component = DIALOG;
   protected readonly defaultOptions = DEFAULT_OPTIONS;
}

Вам нужно лишь задать опции по умолчанию и ваш компонент.

Диалоги, как и все в Taiga UI, используют ng-polymorpheus для кастомизации контента. Узнать больше о том, как создавать гибкие, не зависящие от модели данных компоненты, вы можете из этой статьи.

Захват фокуса реализован директивой tuiFocusTrap. Поскольку фокус может попадать в выпадашки, стоящие позже по DOM, и мы можем иметь несколько открытых диалогов, ловушки игнорируют переход фокуса на нижестоящие элементы. Если же фокус перемещается куда-то выше по дереву — мы возвращаем его назад:

@HostListener('window:focusin.silent', ['$event.target'])
onFocusIn(node: Node) {
   if (containsOrAfter(this.elementRef.nativeElement, node)) {
       return;
   }

   const focusable = getClosestKeyboardFocusable(
       this.elementRef.nativeElement,
       false,
       this.elementRef.nativeElement,
   );

   if (focusable) {
       focusable.focus();
   }
}

Для блокировки скролла используется комбинация из директивы и небольшой логики внутри root-компонента. От root требуется скрыть скроллбар при открытии диалога, в то время как директива tuiOverscroll берет на себя скролл с помощью тача или колеса мыши. Существует CSS-правило overscroll-behavior. Однако его недостаточно. Оно не поможет, если диалог слишком маленький, чтобы не иметь внутреннего скролла. Поэтому мы создали специальную директиву с дополнительной логикой, блокирующей скролл, если он будет происходить в родительских контейнерах.

Бонус: что еще делает tui-root?

Мы обсудили все, что касается порталов. Давайте посмотрим, что еще заложено в root-компонент. Вы видели в шаблоне tui-scroll-controls. Это кастомный скроллбар, контролирующий глобальную прокрутку окна. Также вы могли заметить именованную проекцию контента типа <ng-content select="tuiOverDialogs"></ng-content>. С помощью этих слотов вы можете подсунуть свое содержимое между слоями Taiga UI. Например, если вы используете другие решения для нотификаций и хотите правильно поместить их по глубине приложения.

Еще root регистрирует несколько event manager plugin`ов в DI. Подробнее об этом — в отдельной статье. Важно, чтобы TuiRootModule шел после BrowserModule, чтобы плагины были зарегистрированы в правильном порядке. Но не волнуйтесь: если вы ошибетесь — увидите сообщение в консоли.

Это все, что я хотел рассказать о порталах и root-компоненте. Taiga UI уже в open-source, и вы можете взглянуть на нее на «Гитхабе» и утянуть с npm. Вы можете погулять по демопорталу с документацией или поиграться вживую с этим StackBlitz-стартером. Не теряйтесь, в будущем мы обязательно расскажем больше про интересные фичи, которые у нас есть!

Автор: Александр Инкин

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js