Привет! Меня зовут Даниил Замешаев, я frontend-разработчик в компании МойСклад, и в том числе занимаюсь развитием внутреннего UIKIT компании.

В этой статье я расскажу про подход к анализу дизайнерских требований для frontend-разработчиков. На примере реального кейса я хочу поговорить о двух практических вещах:

  • как анализировать дизайнерские требования так, чтобы на выходе получать понятные технические требования, пригодные для реализации;

  • как решать, какие из получившихся требований можно оставить декларативными (через типизацию), а какими лучше управлять в рантайме.

В основном я буду говорить про UIKIT и компоненты дизайн-системы, но сами принципы применимы к любым макетам и требованиям от дизайнеров, аналитиков и продактов.

В чем основная боль

Когда дизайнер формулирует требования к компоненту UIKIT, его оптика настроена не на техническую реализацию, а на продуктовые сценарии, пользовательский опыт и смысловую нагрузку компонента. В то время как оптика frontend-разработчика скорее настроена на разделение ответственности между UI-компонентом и бизнес-логикой.

И именно в этой точке возникает напряжение, дизайнер описывает поведение системы, а разработчик пытается спроектировать UI-компонент.

Требование, которое абсолютно уместно и логично в рамках дизайнерской оптики, часто совершенно не укладывается в инженерный подход frontend-разработчика.

Вот реальный пример из требований к компоненту Chip.

Здесь дизайнер описывает случаи, в которых состояние warning должно меняться на critical. Это логично с точки зрения продуктовых сценариев, но у разработчика сразу возникает мысль, что компонент должен быть «глупым».

Компоненту приходит состояние извне и он должен уметь только корректно его отобразить. Поэтому зашивать, например, временную логику смены состояния внутрь UIKIT плохая идея.

Почти в любом дизайнерском ТЗ можно найти такие же смешанные уровни ответственности.

Уровни ответственности

Я предлагаю выделять три уровня ответственности при анализе требований дизайнера:

  • Component уровень — то, что может и должно быть частью UI-компонента.

  • Pattern уровень — требования, которые не являются бизнес-логикой, но и не должны жить внутри компонента. Обычно это правила использования и композиции.

  • Business уровень — требования, зависящие от домена и пользовательских сценариев.

Component уровень

Здесь находятся требования, которые отвечают на вопросы:

  • как компонент выглядит в разных состояниях;

  • какие у него интерактивные зоны;

  • как работает фокус и клавиатура;

  • как устроены варианты и размеры;

  • какие комбинации визуальных элементов допустимы.

UI-компонент на этом уровне не объясняет «почему». Он получает декларативные props и корректно их отображает.

Классический пример — таблицы состояний и интерактивности.

Но бывают и пограничные ситуации. Например, в Chip есть несколько типов иконок:

  • иконка слева всегда декоративная;

  • иконка справа может быть частью клика по чипсу или отдельной кнопкой;

  • иконка-крестик всегда кнопка и всегда выполняет сброс.

Здесь легко захотеть сделать все максимально абстрактным. Но дизайнер явно фиксирует семантику крестика — это именно действие сброса.

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

Важно понимать, что эти уровни не являются строгой формальной системой. Это способ мышления, который помогает осознанно принимать архитектурные решения.

Pattern уровень

Pattern уровень — это правила композиции, а не логика отдельного компонента.

Правило такое, если среди требований появляются правила поведения компонента в связке с другими компонентами — это почти всегда Pattern уровень.

Проще понять на примерах из требований к компоненту Chip.

Chip — изолированный UI-элемент. Понятие «группа» существует только на уровне композиции.

А чипс «Все» в сценариях фильтрации фактически становится кнопкой сброса. Это не UI-логика, а правило конкретного паттерна использования.

При этом такие требования не зависят от конкретного экрана, они универсальны для сценариев, где чипсы образуют смысловую группу.

Поэтому этот уровень лучше фиксировать не в логике компонента, а через гайды и примеры. В моем случае, через отдельную вкладку в Storybook.

Business уровень

Требования бизнес-уровня можно определить, ответив на несколько вопросов:

  • зависит ли поведение от пользовательского сценария?

  • требуется ли смысловая интерпретация событий?

  • участвуют ли асинхронные операции?

Если хотя бы на один вопрос ответ «да» — это Business уровень.

Например, в дизайнерских требованиях к компоненту Chip есть описание сценариев, в которых нужно сменить состояние warning или critical на regular.

Например:

Во всех этих случаях присутствует либо асинхронность, либо интерпретация событий, либо доменная логика.

UI-компонент не должен принимать таких решений. Его задача — отрисовать то состояние, которое бизнес-слой уже вычислил. Система решает, должен ли чипс быть warning, critical или regular. Chip лишь принимает это состояние через props и отображает.

В чем польза такого деления

Во-первых, это помогает не заносить доменную логику в дизайн-систему, компоненты остаются масштабируемыми и переиспользуемыми.

Во-вторых, это позволяет сформировать API, в котором есть только то, что компонент действительно может гарантировать.

И в-третьих, это дает опору для технических решений:

  • что выражать типами;

  • что нормализовать в рантайме;

  • что фиксировать через гайды;

  • а что вообще не должно быть частью компонента.


Как это выразилось в коде: API против runtime

После разложения требований возникает ключевой практический вопрос: какие из них должны стать частью API компонента, а какими лучше управлять в рантайме.

Я использую простой подход, декларативные ограничения через типы и runtime-нормализация через helpers.

Типы как декларативные ограничения API

TypeScript я использую там, где комбинация пропсов просто не имеет смысла и должна ломать сборку.

Например:

  • индикаторы описаны как union-режимы (ChipIndicators), чтобы нельзя было собрать невозможную комбинацию;

    /** Индикаторы: 4 режима, TS запрещает count + novelty:number */
    type IndicatorNone = {
      indicatorMode?: 'none';
      count?: never;
      novelty?: never;
    };
    
    type IndicatorNoveltyDot = {
      indicatorMode: 'novelty';
      novelty: 'dot';
      count?: never;
    };
    
    type IndicatorNoveltyNumber = {
      indicatorMode: 'novelty';
      novelty: 'number';
      count: number;
    };
    
    type IndicatorCountOnly = {
      indicatorMode: 'count';
      count: number;
      novelty?: never;
    };
    
    type IndicatorCountPlusDot = {
      indicatorMode: 'countDot';
      count: number;
      novelty?: never;
    };
    
    export type ChipIndicators =
      | IndicatorNone
      | IndicatorNoveltyDot
      | IndicatorNoveltyNumber
      | IndicatorCountOnly
      | IndicatorCountPlusDot;
  • для размера compact через ChipSizeGate запрещены leadingIcon и clearable;

    // Ограничения по size - compact: запрещены leadingIcon и clearable (и связанные поля), разрешён trailing icon
    type ChipRegularSizeGate = {
      size?: 'regular';
    };
    
    type ChipCompactSizeGate = {
      size: 'compact';
      leadingIcon?: never;
      clearable?: never;
      onClear?: never;
      clearIconHint?: never;
    };
    
    type ChipSizeGate = ChipRegularSizeGate | ChipCompactSizeGate;
  • legacy и new API жестко разделены через never.

    // Барьер от смешивания legacy/new: запрещаем legacy-поля в new API.
    type ChipLegacyFieldsForbiddenInNew = {
      isActive?: never;
      onClick?: never;
      children?: never;
      overrideActiveClass?: never;
      isTextEllipsis?: never;
      icon?: never;
      iconPosition?: never;
      bodyTextClassName?: never;
      small?: never;
    };

Типы здесь не описывают бизнес-логику. Они фиксируют форму API и запрещают невозможные состояния.

Проще говоря, типы отвечают за то, что разработчик вообще может передать в компонент.

Runtime-нормализация через helpers

Но даже при жестком API остаются требования, зависящие от runtime-состояния: variant, disabled, count, size.

Здесь появляется слой helpers:

  • resolveTrailing и resolveClear учитывают disabled, подставляют aria-label, делают click no-op;

    const resolveClear = (args: {
      size: ChipSize;
      clearable: ChipNewProps['clearable'];
      onClear: ChipNewProps['onClear'];
      clearIconHint: ChipNewProps['clearIconHint'];
    }): ResolvedClear => {
      const { size, clearable, onClear, clearIconHint } = args;
    
      if (size === 'compact' || !clearable) {
        return { mode: 'none' };
      }
    
      const buttonMeta = resolveButtonMeta({
        hintText: clearIconHint,
        onClick: onClear,
      });
    
      return {
        mode: 'button',
        ...buttonMeta,
      };
    };
  • resolveTextBehavior валидирует maxWidthPx.

    const resolveTextBehavior = (props: {
      constrainWidth?: boolean;
      maxWidthPx?: number;
    }): ChipTextBehaviorResolved => {
      const constrainWidth = props.constrainWidth ?? true;
    
      if (!constrainWidth) {
        return { constrainWidth: false };
      }
    
      const maxWidthPx = isPositiveInt(props.maxWidthPx)
        ? props.maxWidthPx
        : DEFAULT_MAX_WIDTH_PX;
    
      return {
        constrainWidth: true,
        maxWidthPx,
      };
    };
  • Это уже не структура API, а политика отображения. Хорошая формула здесь такая:

    TypeScript говорит, что можно передать. Helpers решают, что из этого реально показать.

    Компонент как тупой renderer

    В результате сам компонент ничего не решает. Вся логика вынесена в resolveProps. Chip получает нормализованное состояние и просто его рендерит. Он не знает, почему стал critical, откуда пришёл count и что означает novelty.

    В JSX нет продуктовых сценариев, там только отображение.

    Итог

    Такой подход позволяет держать UI-компонент глупым, API — честным, а продуктовую логику — вне дизайн-системы.

    Практически это сводится к нескольким правилам:

    • всегда сначала дели требования по ответственности;

    • типы используй для невозможных состояний API;

    • helpers — для нормализации допустимых runtime-сценариев;

    • UI-компонент не должен знать про бизнес;

    • legacy живёт через resolve-слой, а не через ветки в JSX.

    В итоге компоненты не проектируются от Figma. Они проектируются от ответственности.