Порталы
При фронтенд-разработке иногда возникает задача отобразить компонент (например, модальное окно или всплывающую подсказку) в другом месте, а не там, где он был запущен. Проблема в том, что элемент пользовательского интерфейса должен отображаться в другой части дерева 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-подобную реализацию портала.
Фундаментальными проблемами, требующими решения, являются:
- Определять место отображения всплывающего окна в приложении (назовём этот компонент
<Portal>
); - Иметь способ взаимодействия с
<Portal>
, чтобы сообщить ему, когда и какой компонент должен быть отображён (из компонента, вызывающего всплывающее окно). Это достигается с помощью компонента<PortalProvider/>
.
Решение
Давайте разобьём решение на этапы:
- Создадим компонент
PortalProvider
, отвечающий за управление порталами; - Поместим
<PortalProvider>
в компонент верхнего уровняlayout.tsx
; - Создадим
PortalAPI
, который может быть использован для взаимодействия сPortalProvider
; - Создадим компонент
<Portal>
, который отображает содержимое портала.
Использование PortalProvider
Предположим, что у нас уже есть PortalProvider
, и сначала остановимся на том, как он используется.
- Мы получаем доступ к API
PortalProvider
через:const portal = useContext(PortalAPI);
- Затем мы используем API
PortalProvider
для показа всплывающего окна:portal('modal', <PopupExample name="World" />)
- Наконец, мы можем использовать
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, который может быть использован для показа/скрытия всплывающего окна.
- Создайте
PortalProviderContext
, который будет использоваться для взаимодействия сPortalProvider
.useContextProvider(PortalProviderContext, { show: $(<T extends {}>(component: Component<T>, props: T) => {...}), hide: $(() => {...}) });
- Условное отображение компонента:
{ // Условное отображение модального окна 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>
);
});