Перейти к содержимому

16. Директивы

Яша, директивы в Solid — это концепция, вдохновлённая Vue и Angular, но реализованная по-настоящему элегантно. Вместо HOC или хуков для работы с DOM — просто функция + use:directive. Хочешь drag-and-drop? Tooltip? Click-outside? Intersection Observer? Всё это — директивы! 🎯


Директива — это функция с двумя аргументами:

// Сигнатура директивы
type DirectiveFn<T = undefined> = (
element: HTMLElement, // DOM-элемент
accessor: () => T // getter для параметра (реактивный!)
) => void;
// Простейшая директива — автофокус
function autofocus(el: HTMLElement, _accessor: () => undefined) {
el.focus();
}
// Использование
function App() {
return <input use:autofocus />;
}

💡 accessor — это функция, а не значение. Вызови её accessor(), чтобы получить текущее значение. Это нужно для реактивности!


// Директива с параметром
function tooltip(el: HTMLElement, accessor: () => string) {
const tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip';
// Реактивно обновляем текст при изменении параметра
createEffect(() => {
tooltipEl.textContent = accessor(); // вызываем для реактивности
});
el.addEventListener('mouseenter', () => {
document.body.appendChild(tooltipEl);
const rect = el.getBoundingClientRect();
tooltipEl.style.top = `${rect.top - 30}px`;
tooltipEl.style.left = `${rect.left}px`;
});
el.addEventListener('mouseleave', () => {
tooltipEl.remove();
});
// Очистка при размонтировании
onCleanup(() => tooltipEl.remove());
}
// Использование
function App() {
const [text, setText] = createSignal('Наведи на меня!');
return (
<>
<button use:tooltip={text()}>Кнопка с тултипом</button>
<input onInput={(e) => setText(e.target.value)} />
</>
);
}

Классический пример — закрытие модального окна при клике снаружи:

import { onCleanup } from 'solid-js';
function clickOutside(el: HTMLElement, accessor: () => () => void) {
const handler = (e: MouseEvent) => {
if (!el.contains(e.target as Node)) {
accessor()(); // accessor() → callback, callback() → вызов
}
};
document.addEventListener('click', handler, { capture: true });
onCleanup(() => document.removeEventListener('click', handler, { capture: true }));
}
// Использование
function Modal() {
const [open, setOpen] = createSignal(false);
return (
<Show when={open()}>
<div class="overlay">
<div
class="modal"
use:clickOutside={() => setOpen(false)}
>
<h2>Модальное окно</h2>
<p>Кликни снаружи, чтобы закрыть</p>
</div>
</div>
</Show>
);
}

function draggable(el: HTMLElement, accessor: () => { onMove?: (x: number, y: number) => void }) {
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let isDragging = false;
const onMouseDown = (e: MouseEvent) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
startLeft = el.offsetLeft;
startTop = el.offsetTop;
el.style.cursor = 'grabbing';
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
el.style.left = `${startLeft + dx}px`;
el.style.top = `${startTop + dy}px`;
accessor().onMove?.(startLeft + dx, startTop + dy);
};
const onMouseUp = () => {
isDragging = false;
el.style.cursor = 'grab';
};
el.style.position = 'absolute';
el.style.cursor = 'grab';
el.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
onCleanup(() => {
el.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
}
// Использование
function App() {
const [pos, setPos] = createSignal({ x: 100, y: 100 });
return (
<div style={{ position: 'relative', height: '400px' }}>
<div
use:draggable={{ onMove: (x, y) => setPos({ x, y }) }}
style={{ width: '100px', height: '100px', background: '#2c67d5' }}
>
Тащи меня!
</div>
<p>Позиция: x={pos().x}, y={pos().y}</p>
</div>
);
}

function intersectionObserver(
el: HTMLElement,
accessor: () => { threshold?: number; onVisible?: () => void; onHidden?: () => void }
) {
const observer = new IntersectionObserver(
(entries) => {
const { onVisible, onHidden } = accessor();
entries.forEach(entry => {
if (entry.isIntersecting) onVisible?.();
else onHidden?.();
});
},
{ threshold: accessor().threshold ?? 0.5 }
);
observer.observe(el);
onCleanup(() => observer.disconnect());
}
// Использование — ленивая загрузка
function LazyImage({ src }: { src: string }) {
const [loaded, setLoaded] = createSignal(false);
return (
<div
use:intersectionObserver={{
threshold: 0.1,
onVisible: () => setLoaded(true),
}}
style={{ min-height: '200px' }}
>
<Show when={loaded()}>
<img src={src} alt="Lazy loaded" />
</Show>
</div>
);
}

// Чтобы TypeScript знал о директиве — декларация модуля
declare module 'solid-js' {
namespace JSX {
interface Directives {
tooltip: string;
clickOutside: () => void;
draggable: { onMove?: (x: number, y: number) => void };
intersectionObserver: { threshold?: number; onVisible?: () => void };
}
}
}
// Теперь TypeScript проверяет типы параметров:
<div use:tooltip="Привет!">OK</div>
<div use:tooltip={42}>Ошибка!</div> // Type error!