Библиотека Popper.js предоставляет мощный механизм позиционирования элементов, однако её API изначально ориентирован на гибкость, а не на строгую типобезопасность. При использовании в крупных приложениях это приводит к накоплению скрытых ошибок: некорректные модификаторы, несовместимые опции, неправильные типы элементов.
Типобезопасные обёртки решают эти проблемы, добавляя строгую проверку на этапе компиляции и формируя устойчивый контракт между различными слоями приложения.
Popper.js (v2) уже поставляется с типами (TypeScript), но они достаточно обобщённые:
import { createPopper, Instance, Placement, Modifier } from '@popperjs/core';
const instance: Instance = createPopper(reference, popper, {
placement: 'top',
});
Основные проблемы:
placement — строковый union, но без ограничений
контекстаmodifiers — массив слабо типизированных объектовПервый шаг — ограничение допустимых значений и структур:
type StrictPlacement =
| 'top'
| 'bottom'
| 'left'
| 'right';
interface StrictPopperOptions {
placement: StrictPlacement;
offset?: [number, number];
strategy?: 'absolute' | 'fixed';
}
Теперь конфигурация становится предсказуемой:
const options: StrictPopperOptions = {
placement: 'top',
offset: [0, 8],
};
Создание обёртки позволяет контролировать входные параметры:
function createStrictPopper(
reference: HTMLElement,
popper: HTMLElement,
options: StrictPopperOptions
): Instance {
return createPopper(reference, popper, {
placement: options.placement,
strategy: options.strategy,
modifiers: [
{
name: 'offset',
options: {
offset: options.offset ?? [0, 0],
},
},
],
});
}
Преимущества:
Модификаторы — ключевой механизм Popper.js. Без типизации они становятся источником ошибок.
Создание строгого интерфейса:
interface OffsetModifier {
name: 'offset';
options: {
offset: [number, number];
};
}
interface FlipModifier {
name: 'flip';
options?: {
fallbackPlacements?: StrictPlacement[];
};
}
type StrictModifier = OffsetModifier | FlipModifier;
Теперь массив модификаторов строго контролируется:
const modifiers: StrictModifier[] = [
{
name: 'offset',
options: { offset: [0, 10] },
},
{
name: 'flip',
options: { fallbackPlacements: ['top', 'bottom'] },
},
];
Для масштабируемых систем важно предусмотреть расширение:
type BaseOptions<TModifiers> = {
placement: StrictPlacement;
modifiers?: TModifiers[];
};
function createTypedPopper<TModifiers>(
reference: HTMLElement,
popper: HTMLElement,
options: BaseOptions<TModifiers>
): Instance {
return createPopper(reference, popper, {
placement: options.placement,
modifiers: options.modifiers as any,
});
}
Использование:
const instance = createTypedPopper<StrictModifier>(
ref,
pop,
{
placement: 'bottom',
modifiers,
}
);
Типобезопасность позволяет запрещать некорректные комбинации:
type FixedStrategyOptions = {
strategy: 'fixed';
placement: 'top' | 'bottom';
};
type AbsoluteStrategyOptions = {
strategy: 'absolute';
placement: StrictPlacement;
};
type SmartOptions = FixedStrategyOptions | AbsoluteStrategyOptions;
Теперь:
const options: SmartOptions = {
strategy: 'fixed',
placement: 'left', // ошибка
};
Popper принимает Element | VirtualElement. Часто
требуется уточнение:
type ReferenceElement = HTMLElement;
type PopperElement = HTMLElement;
function isHTMLElement(el: Element): el is HTMLElement {
return el instanceof HTMLElement;
}
Применение в обёртке:
function safeCreatePopper(
reference: Element,
popper: Element,
options: StrictPopperOptions
): Instance {
if (!isHTMLElement(reference) || !isHTMLElement(popper)) {
throw new Error('Invalid elements');
}
return createStrictPopper(reference, popper, options);
}
Обёртка может включать дополнительную бизнес-логику:
class PopperController {
private instance: Instance | null = null;
constructor(
private reference: HTMLElement,
private popper: HTMLElement
) {}
init(options: StrictPopperOptions) {
this.instance = createStrictPopper(
this.reference,
this.popper,
options
);
}
update() {
this.instance?.update();
}
destroy() {
this.instance?.destroy();
this.instance = null;
}
}
Popper имеет внутреннее состояние (state), которое можно
типизировать:
import { State } from '@popperjs/core';
function logPlacement(state: State) {
const placement: Placement = state.placement;
console.log(placement);
}
Расширение:
interface ExtendedState extends State {
customData?: {
initializedAt: number;
};
}
Фабричный подход упрощает создание стандартных конфигураций:
function createTooltipPopper(
reference: HTMLElement,
tooltip: HTMLElement
): Instance {
return createStrictPopper(reference, tooltip, {
placement: 'top',
offset: [0, 8],
});
}
В React:
type UsePopperOptions = StrictPopperOptions;
function useTypedPopper(
reference: HTMLElement | null,
popper: HTMLElement | null,
options: UsePopperOptions
) {
useEffect(() => {
if (!reference || !popper) return;
const instance = createStrictPopper(reference, popper, options);
return () => instance.destroy();
}, [reference, popper, options]);
}
Типобезопасные обёртки:
Слишком строгая типизация может ограничивать возможности Popper.js. Оптимальный подход:
Типобезопасные обёртки:
interface AppPopperOptions {
placement: StrictPlacement;
offset?: [number, number];
}
function createAppPopper(
ref: HTMLElement,
pop: HTMLElement,
options: AppPopperOptions
) {
return createPopper(ref, pop, {
placement: options.placement,
modifiers: [
{
name: 'offset',
options: {
offset: options.offset ?? [0, 8],
},
},
{
name: 'flip',
},
],
});
}
Такой подход формирует устойчивый, масштабируемый и безопасный слой взаимодействия с Popper.js.