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

Синхронизация выделения: диаграмма ⇄ таблица

Одна и та же сущность рисуется в интерфейсе дважды — узлом в диаграмме и строкой в таблице. При наведении или клике в одной панели нужно подсветить совпадение в другой. Классическая UI-задача, и в любом фреймворке она решается через одно из трёх:

  • Поднять состояние в общий родитель — диаграмма и таблица навсегда связаны через него.
  • Пробрасывать selectedId + onSelect пропсами через все компоненты между ними — бойлерплейт, утечка ответственности.
  • Общий стор, подключённый к каждому листу — со временем превращается в god-object, когда добавляются ещё три синхронизируемые панели.

Triggery делит задачу пополам:

  • правило («событие ховера → “эта сущность теперь под мышкой”») живёт в одном файле триггера,
  • результирующее состояние живёт в крошечном сторе рядом,
  • две панели — чистые потребители, они не знают друг о друге, только о сторе.
Открыть в StackBlitz Открыть пример на GitHub
  • README.md обзорное описание
  • index.html точка входа Vite
  • Директорияsrc/
    • App.tsx продьюсеры + реакторы
    • main.tsx точка входа
    • store.ts крошечный стор — реактор пишет, обе панели читают
    • Директорияtriggers/
      • index.ts правило — события, условия, действия, обработчик

Маленькая доменная модель — Order, Invoice, Customer, Product, Shipment — рисуется в двух независимых панелях:

  • Диаграмма на SVG с узлами и связями.
  • Таблица с одной строкой на сущность.

Требуемое поведение:

  1. Навёл на строку — соответствующий узел подсвечивается.
  2. Навёл на узел — соответствующая строка подсвечивается.
  3. Кликнул в любой панели — выделение фиксируется.
  4. Панели монтируются независимо. Если на странице только таблица — всё работает.
src/triggers/selection.trigger.ts
import { createTrigger } from '@triggery/core';

export const selectionTrigger = createTrigger<{
  events: {
    'entity:hover':  string | null;
    'entity:select': string | null;
  };
  actions: {
    setHovered:  string | null;
    setSelected: string | null;
  };
}>({
  id: 'entity-selection-sync',
  events: ['entity:hover', 'entity:select'],
  handler({ event, actions }) {
    if (event.name === 'entity:hover')  actions.setHovered?.(event.payload);
    else                                actions.setSelected?.(event.payload);
  },
});

Два события, два действия — всё правило целиком.

Регистрации useAction работают по принципу last-mount-wins — у каждого действия активен только самый свежезамонтированный обработчик. Это правильно для «я — побочный эффект этого правила». Но не годится для fan-out на N строк сразу. Поэтому: один реактор пишет в стор, каждая строка/узел читает из стора.

src/store.ts
import { useSyncExternalStore } from 'react';

type State = { hoveredId: string | null; selectedId: string | null };
let state: State = { hoveredId: null, selectedId: null };
const listeners = new Set<() => void>();

const subscribe = (l: () => void) => {
  listeners.add(l);
  return () => listeners.delete(l);
};
const getSnapshot = () => state;

export const setHoveredId  = (id: string | null) => {
  if (state.hoveredId  === id) return;
  state = { ...state, hoveredId: id };  for (const l of listeners) l();
};
export const setSelectedId = (id: string | null) => {
  if (state.selectedId === id) return;
  state = { ...state, selectedId: id }; for (const l of listeners) l();
};
export const useSelection = () => useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

В большом приложении это был бы Zustand / Redux / Jotai / Signals. Здесь оставляем без сторонних зависимостей.

src/App.tsx
function StoreBridge() {
  useAction(selectionTrigger, 'setHovered',  setHoveredId);
  useAction(selectionTrigger, 'setSelected', setSelectedId);
  return null;
}

Один монтаж. Конкурирующих реакторов нет. Положи это где угодно под провайдером рантайма.

Каждая панель отправляет события на mouse-in / click и читает выделение через useSelection(). Друг с другом не разговаривают.

Узел диаграммы (одна панель)
function Node({ entity }: { entity: Entity }) {
  const hover  = useEvent(selectionTrigger, 'entity:hover');
  const select = useEvent(selectionTrigger, 'entity:select');
  const { hoveredId, selectedId } = useSelection();

  const hovered  = hoveredId  === entity.id;
  const selected = selectedId === entity.id;

  return (
    <g
      onMouseEnter={() => hover(entity.id)}
      onMouseLeave={() => hover(null)}
      onClick={()      => select(entity.id)}
    >
      <rect fill={selected ? '#af37c5' : hovered ? '#e6dffa' : '#fff'} />
    </g>
  );
}
Строка таблицы (вторая панель)
function Row({ entity }: { entity: Entity }) {
  const hover  = useEvent(selectionTrigger, 'entity:hover');
  const select = useEvent(selectionTrigger, 'entity:select');
  const { hoveredId, selectedId } = useSelection();

  return (
    <tr
      onMouseEnter={() => hover(entity.id)}
      onMouseLeave={() => hover(null)}
      onClick={()      => select(entity.id)}
      style={{ background: selectedId === entity.id ? '#af37c5'
                         : hoveredId  === entity.id ? '#e6dffa' : 'transparent' }}
    >
      <td>{entity.id}</td><td>{entity.label}</td>
    </tr>
  );
}

Две панели, без общего родителя, без проброса selectedId / onSelect пропсами. Удалить таблицу ничего не сломает: событие entity:hover всё равно отправляется, стор всё равно обновляется, никто не читает — жизнь продолжается.

Сам стор-паттерн можно было бы сделать и без Triggery. Что добавляет Triggery:

  • Один файл хранит правило. Сегодня это «событие → запись в стор». Завтра логика может ветвиться — отправить аналитику на entity:select, игнорировать ховер при включённом DND, заглушить события из таблицы пока диаграмма в полноэкране. Каждое новое условие — строка в обработчике, а не россыпь по компонентам.
  • Код продьюсера не меняется при изменении правила. <Node> умеет только одно — отправить entity:hover со своим id. Любые новые поведения (debounce, скоупы по вкладкам, перенаправление в другой стор) для продьюсера невидимы.
  • Скоупированные варианты бесплатно. Если та же пара диаграмма + таблица живёт в двух соседних дашбордах, оборачиваем каждый в <TriggerScope id="left"> / <TriggerScope id="right">, и триггер читает события только из своего скоупа. См. Области видимости.
  • Триггер тестируется независимо. Отправил entity:hover, проверил, что mock стора вызван с нужным id. Без DOM, без рендера, без провайдеров — см. Модульные тесты.
  • Мультивыделение — меняем тип payload на Array<string>. Хелпер записи в стор сам решает, как объединять значения.
  • Навигация с клавиатуры — отправляем entity:hover из keydown-обработчика на диаграмме или таблице; обе панели обновляются, не зная откуда событие пришло.
  • Третий UI (график, JSON-инспектор) — читаем useSelection() в этом компоненте. Существующие панели не трогаем.