Порталы

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

В других фреймворках эта проблема часто решается с помощью специальных API, таких как createPortal(). Однако, такие API не очень хорошо работают с рендером на стороне сервера, поэтому необходим альтернативный подход.

Qwik UI

К счастью, для этого существует встроенное поведение, называемое top layer. Команда Qwik UI проделала огромную работу, восполнив пробелы и позволив нам использовать это поведение в производственной среде.

Модальные окна

Мы используем модальные окна, когда не хотим, чтобы пользователь взаимодействовал с остальной частью страницы. Остальное содержимое инертно, с ним невозможно взаимодействовать.

Модальный компонент Qwik UI использует метод showModal элемента диалога, который хорошо поддерживается браузерами и автоматически справляется с размещением пользовательского интерфейса за пределами HTML-документа.

Он также включает в себя такие функции, как блокировка фокуса и прокрутки, диалоговые окна оповещения, поддержка автоматической анимации появления и исчезания, а также анимации заднего плана. На момент написания статьи поддержка диалогового элемента составляет 96 %.

Немодальные элементы интерфейса

Если элемент пользовательского интерфейса может взаимодействовать с остальной частью страницы, то он не является модальным.

Примерами немодальных компонентов являются:

  • Popovers
  • Overlays
  • Toasts
  • Tooltips
  • Dropdown menus
  • Selects
  • Comboboxes

MDN's popover API заменяет необходимость порталов в немодальных компонентах. Поддержка есть во всех основных браузерах. На момент написания статьи она составляет ~73%.

Qwik UI взял на себя труд предоставить полифилл с функциями, равными родным спецификациям. Вы можете использовать поведение API Popover в производственной среде уже сегодня с помощью компонента Qwik UI's Popover.

В случае с такими компонентами, как select или combobox, Qwik UI предоставляет опциональную возможность "плавающего" поведения. Например, когда поле списка привязывается к элементу ввода. Вы можете сделать это, добавив floating={true} к компоненту Popover. При этом будет выполнено немного дополнительного javascript, необходимого для плавающего поведения.

Это поведение намеренно сделано опциональным, в какой-то момент CSS Anchor API предоставит собственное решение, и поэтому должен быть легкий путь миграции, когда он получит более широкую поддержку.

Поскольку эти решения построены на основе нативных спецификаций, это означает, что нам нужно меньше javascript для предварительной выборки, а значит, и меньше работы!

Всплывающие и модальные компоненты Qwik UI можно использовать независимо от мета-фреймворка или микрофронтенда, если есть поддержка Qwik.

Пользовательские порталы

Если поведение вышеперечисленных компонентов не подходит для вашего конкретного случая, в Qwik также есть возможность создать собственный компонент портала. Мы создадим модальный компонент с нуля.

Ниже представлена реализация портала с использованием SSR Qwik City. Если вы используете несколько фронтенд-фреймворков наряду с Qwik, вы можете предпочесть React-подобную реализацию портала.

Фундаментальными проблемами, требующими решения, являются:

  1. Определять место отображения всплывающего окна в приложении (назовём этот компонент <Portal>);
  2. Иметь способ взаимодействия с <Portal>, чтобы сообщить ему, когда и какой компонент должен быть отображён (из компонента, вызывающего всплывающее окно). Это достигается с помощью компонента <PortalProvider/>.

Решение

Давайте разобьём решение на этапы:

  1. Создадим компонент PortalProvider, отвечающий за управление порталами;
  2. Поместим <PortalProvider> в компонент верхнего уровня layout.tsx;
  3. Создадим PortalAPI, который может быть использован для взаимодействия с PortalProvider;
  4. Создадим компонент <Portal>, который отображает содержимое портала.

Использование PortalProvider

Предположим, что у нас уже есть PortalProvider, и сначала остановимся на том, как он используется.

  1. Мы получаем доступ к API PortalProvider через:
    const portal = useContext(PortalAPI);
  2. Затем мы используем API PortalProvider для показа всплывающего окна:
    portal('modal', <PopupExample name="World" />)
  3. Наконец, мы можем использовать PortalCloseAPI для скрытия всплывающего окна:
    const portalClose = useContext(PortalCloseAPI);
    portalClose();

Полный исходный код можно найти здесь:

import {
  $,
  component$,
  useContext,
  useStylesScoped$,
  useTask$,
} from '@builder.io/qwik';
import { PortalCloseAPIContextId, PortalAPI } from './portal-provider';
import PopupExampleCSS from './popup-example.css?inline';
import { useLocation } from '@builder.io/qwik-city';
 
export default component$(() => {
  // Retrieve the portal API
  const portal = useContext(PortalAPI);
  // This function is used to open the modal.
  // Portals can be named and each portal can have multiple items rendered into it.
  const openModal = $(() => portal('modal', <PopupExample name="World" />));
 
  // Conditionally open the <Portal/> on the server to demonstrate SSR of portals.
  const location = useLocation();
  useTask$(() => {
    location.url.searchParams.get('modal') && openModal();
  });
  return (
    <>
      <div>
        [ <a href="?modal=true">render portal as part of SSR</a> |{' '}
        <a href="?">render portal as part of client interaction</a> ]
      </div>
      <button onClick$={openModal}>Show Modal</button>
    </>
  );
});
 
// This component is shown as a modal.
export const PopupExample = component$<{ name: string }>(({ name }) => {
  useStylesScoped$(PopupExampleCSS);
  // To close a portal retrieve the close API.
  const portalClose = useContext(PortalCloseAPIContextId);
  return (
    <div class="popup-example">
      <h1>MODAL</h1>
      <p>Hello {name}!</p>
      <button onClick$={() => portalClose()}>X</button>
    </div>
  );
});

Стилизация компонента PopupExample имеет вид (portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

Реализация PortalProvider

PortalProvider - это компонент, отвечающий за рендер всплывающего окна. Он также предоставляет контекстный API, который может быть использован для показа/скрытия всплывающего окна.

  1. Создайте PortalProviderContext, который будет использоваться для взаимодействия с PortalProvider.
    useContextProvider(PortalProviderContext, {
     show: $(<T extends {}>(component: Component<T>, props: T) => {...}),
     hide: $(() => {...})
    });
  2. Условное отображение компонента:
    {
      // Условное отображение модального окна
      modal.value && <div class="modal">
        <modal.value.Component {...modal.value.props} />
      </div>
    }

Полностью с реализацией можно ознакомиться здесь:

import {
  $,
  Slot,
  component$,
  createContextId,
  useContext,
  useContextProvider,
  useSignal,
  useStylesScoped$,
  type ContextId,
  type QRL,
  type Signal,
  type JSXOutput,
} from '@builder.io/qwik';
import CSS from './portal-provider.css?inline';
 
// Определение публичного API для открытия порталов
export const PortalAPI = createContextId<
  /**
   * Add JSX to a portal.
   * @param name portal name.
   * @param jsx to add.
   * @param contexts to add to the portal.
   * @returns A function used for closing the portal.
   */
  QRL<
    (name: string, jsx: JSXOutput, contexts?: ContextPair<any>[]) => () => void
  >
>('PortalProviderAPI');
 
export type ContextPair<T> = { id: ContextId<T>; value: T };
 
// Определение публичного API для закрытия порталов
export const PortalCloseAPIContextId =
  createContextId<QRL<() => void>>('PortalCloseAPI');
 
// Внутренний контекст для управления порталами
const PortalsContextId = createContextId<Signal<Portal[]>>('Portals');
 
interface Portal {
  name: string;
  jsx: JSXOutput;
  close: QRL<() => void>;
  contexts: Array<ContextPair<any>>;
}
 
export const PortalProvider = component$(() => {
  const portals = useSignal<Portal[]>([]);
  useContextProvider(PortalsContextId, portals);
 
  // Provide the public API for the PopupManager for other components.
  useContextProvider(
    PortalAPI,
    $((name: string, jsx: JSXOutput, contexts?: ContextPair<any>[]) => {
      const portal: Portal = {
        name,
        jsx,
        close: null!,
        contexts: [...(contexts || [])],
      };
      portal.close = $(() => {
        portals.value = portals.value.filter((p) => p !== portal);
      });
      portal.contexts.push({
        id: PortalCloseAPIContextId,
        value: portal.close,
      });
      portals.value = [...portals.value, portal];
      return portal.close;
    })
  );
  return <Slot />;
});
 
/**
 * ВАЖНО: Для того чтобы <Portal> корректно рендерился в SSR, ему необходимо
 * рендериться ПОСЛЕ вызова открытия портала (установка контента портала
 * ПОСЛЕ отрисовки портала не может быть сделана в SSR, потому что невозможно
 * вернуться к <Portal/> после того, как он был передан клиенту).
 */
export const Portal = component$<{ name: string }>(({ name }) => {
  const portals = useContext(PortalsContextId);
  useStylesScoped$(CSS);
  const myPortals = portals.value.filter((portal) => portal.name === name);
  return (
    <>
      {myPortals.map((portal, key) => (
        <div key={key} data-portal={name}>
          <WrapJsxInContext jsx={portal.jsx} contexts={portal.contexts} />
        </div>
      ))}
    </>
  );
});
 
export const WrapJsxInContext = component$<{
  jsx: JSXOutput;
  contexts: Array<ContextPair<any>>;
}>(({ jsx, contexts }) => {
  contexts.forEach(({ id, value }) => {
    // eslint-disable-next-line
    useContextProvider(id, value);
  });
  return (
    <>
      {/* Workaround: https://github.com/BuilderIO/qwik/issues/4966 */}
      {/* {jsx} */}
      {[jsx].map((jsx) => jsx)}
    </>
  );
});

Стилизация компонента PortalProvider имеет вид (portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

Добавление PortalProvider в корневой файл layout.tsx

import { Slot, component$ } from '@builder.io/qwik';
import { Portal, PortalProvider } from './portal-provider';
 
export default component$(() => {
  // 1. Wrap a root component with a <PortalProvider> to enable portal API.
  //    The <PortalProvider> component will provide a context API to
  //    allow other components to create portals.
  // 2. Add <Portal/> to where you want the portals to be rendered.
  //    (<Portal/>s have names and so you can have multiple <Portal/> locations.)
  return (
    <PortalProvider>
      <Slot />
      <Portal name="modal" />
    </PortalProvider>
  );
});

Участники

Спасибо всем участникам, которые помогли сделать эту документацию лучше!

  • mhevery
  • thejackshelton
  • fabian-hiller
  • igorbabko
  • aendel