Как стать автором
Обновить

Создание кастомизируемого Dropdown для React на TypeScript

Время на прочтение11 мин
Количество просмотров31K

Создание Dropdown компонента - процесс не такой лёгкий, как может показаться на первый взгляд. Необходимо учесть множество мелких, но важных моментов, чтобы разработчику было приятно и удобно им пользоваться. В то же время есть потребность сделать его достаточно гибким и кастомизируемым, с возможностью перемещения с одного проекта на другой или даже настройки под разные нужды внутри одного проекта.

В этой статье будет рассмотрен пример создания такого компонента с использованием React, TypeScript и styled-components (замечу, что использование css-in-js - опционально. Вы можете использовать любой способ стилизации, который вам по душе).


Инициализация компонента

Давайте начнём с того, как наш компонент будет выглядеть для пользователя. Я предлагаю сделать это настраиваемым и завести отдельное свойство - label. В качестве элемента, который видит пользователь до открытия, мы будем показывать то, что передаст разработчик, а children компонента будет содержимым выпадающего меню. В разных ситуациях наш Dropdown может выглядеть по разному. Например, Dropdown в приложении Linear в одном случае показывает единственный выбранный элемент, а во втором - надпись "N labels". В обоих случаях присутствуют иконки. Свойство label позволит гибко управлять отображением элементов и создавать компоненты на основе нашего Dropdown (например, PointsDropdown, LabelsDropdown).

import React, { ReactNode } from "react";
import styled from "styled-components";

type Props = {
  label: ReactNode;
};

export const Dropdown = (props: Props) => {
  const { label } = props;

  return (
    <Root>
      <Control>{label}</Control>
    </Root>
  );
};

const Root = styled.div``;

const Control = styled.button`
  width: 100%;
  margin: 0;
  padding: 0;
`;

Здесь мы создаём два элемента - Root (divControl  (button). Первый служит контейнером для нашего компонента, а второй даёт пользователю возможность сфокусироваться и открыть Dropdown с помощью таба и пробела. Теперь добавим выпадающее меню - основной элемент нашего компонента и напишем для него простейшие стили:

const Menu = styled.menu`
  margin: 1px 0 0;
  padding: 0;
  border: 1px solid #bebebe;
  max-height: 100px;
  overflow-y: auto;
`;

а показывать его будем только если стейт isOpen установлен в значение true:

const [isOpen, setOpen] = useState(false);

const handleOpen = () => setOpen(true);

return (
  <Root>
    <Control onClick={handleOpen} type='button'>{label}</Control>
    {
      isOpen && (
        <Menu>
        </Menu>       
      )
    }
  </Root>
)

В самом простом случае внутри нашего выпадающего меню может быть только один вид элементов - пункт меню:

MenuItem.tsx

import React, { PropsWithChildren } from "react";
import styled from "styled-components";

type Props = {
  active?: boolean;
  disabled?: boolean;
  value: any;
  onClick?(): void;
} & HTMLAttributes<HTMLDivElement>;

export const MenuItem = forwardRef<HTMLDivElement, PropsWithChildren<Props>>((props, ref) => {
  const { active, disabled, children, ...rest } = props;

  return (
    <Root {...rest} ref={ref} disabled={disabled} active={active}>
      {props.children}
    </Root>
  );
});

const Root = styled.div<{ disabled?: boolean; active?: boolean }>`
  padding: 5px 10px;
  cursor: ${(p) => (p.disabled ? "initial" : "pointer")};
  opacity: ${(p) => (p.disabled ? 0.5 : 1)};
  background-color: ${(p) => (p.active ? "#ccc" : "transparent")};
`;

Разберём по частям написанное. Наш компонент обладает четырьмя пропами. Проп active показывает, выделен ли этот элемент в настоящий момент (не важно, курсором или стрелками клавиатуры). Проп disabled не позволяет выбрать данный элемент. Этот проп необязателен и взят для усложнения примера, на случай, если такой статус потребуется сделать в реальном проекте. Пропы value и onClick понадобятся нам позже. Ещё мы здесь используем функцию forwardRef, что тоже пока не используется, но будет в дальнейшем. Помимо этого в типах мы указываем, что компонент может принимать все те же пропы, что принимает обычный div и прокидываем их в Root с помощью спреда.

Содержимое меню

Наш Dropdown пока никак не использует передаваемых в него children - нужно это исправить. Для начала просто выведем проп children как есть:

export const Dropdown = (props: PropsWithChildren<Props>) => {
  const {
    label,
    children,
  } = props;

  ...

  return (
	<Root>
	  <Control onClick={handleOpen} type='button'>{label}<Control>
	  {
		isOpen && (
		  <Menu>
			{children}
		  </Menu>
		)
	  }
	</Root>
  );
}

Теперь мы, наконец, можем применить то, что у нас имеется на данный момент:

App.tsx

type Item = {
  label: string;
  value: number;
}

const items: Item[] = [
  { label: 'Moscow', value: 1 },
  { label: 'London', value: 2 },
  { label: 'Helsinki', value: 3 },
  { label: 'Rome', value: 4 },
  { label: 'Oslo', value: 5 },
];

<Dropdown label='Choose city'>
  {
	items.map(item => (
	  <MenuItem key={item.value} value={item}>
		{item.label}
	  </MenuItem>
	))
  }
</Dropdown>

Выбор элемента

Если вы проверите результат в браузере, то увидите, что Dropdown худо-бедно работает - меню открывается, но нельзя ничего выбрать ни стрелками, ни курсором. Давайте сейчас сосредоточимся на этом. Нам нужно где-то трекать текущий выбранный элемент. Логичней всего делать это в общем для элементов родителе - самом Dropdown. Но, вот незадача, надо ещё как-то сообщать элементу о том, что он выбран. Сейчас это не представляется возможным, так как за рендер элементов у нас отвечает пользователь дропдауна. Мы можем это исправить с помощью функции cloneElement - она позволяет копировать переданный в children элемент и добавлять к нему новые пропы (и в отличие от рендера <child.type ... /> прокидывает key, что немаловажно для нас, так как MenuItem будет много). В нашем случае это проп active, с помощью которого мы будем помечать текущий выбранный элемент. Ещё здесь нам пригодится typescript guard isValidElement - он позволяет убедиться, что перед нами нужный тип.

const [isOpen, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);

...

return (
  <Root>
    <Control onClick={handleOpen} type='button'>{view}</Control>
    {
      isOpen && (
        <Menu>
          {
            Children.map(children, (child, index) => {
              if (isValidElement(child)) {
                return cloneElement(child, {
                  active: index === highlightedIndex,
                  onMouseEnter: () => setHighlightedIndex(index),
                })
              }
            })
          }
        </Menu>
      )
    }
  </Root>
)

Обратите внимание! Так как мы используем Children.map, в <Dropdown> нужно передавать именно коллекцию элементов. Если её обернуть во фрагмент, то всё сломается.

Мы сделали подсветку текущего элемента по hover, теперь пришло время сделать навигацию с помощью клавиш:

...
const length = Children.count(children);

const handleKeyDown = async (ev: KeyboardEvent) => {
  switch (ev.code) {
    case 'ArrowDown':
      ev.preventDefault();
      ev.stopPropagation();
      setHighlightedIndex(highlightedIndex => {
        const index = highlightedIndex === length - 1 ? 0 : highlightedIndex + 1;
        return index;
      });
      break;
    case 'ArrowUp': {
      ev.preventDefault();
      ev.stopPropagation();
      setHighlightedIndex(highlightedIndex => {
        const index = highlightedIndex === 0 ? length - 1 : highlightedIndex - 1;
        return index;
      });
      break;
    }
  }
}

useEffect(() => {
  if (isOpen) {
    document.addEventListener('keydown', handleKeyDown, true);
  }
  
  return () => {
    document.removeEventListener('keydown', handleKeyDown, true);
  }
}, [isOpen]);

Первым делом мы вычисляем количество элементов в children и сохраняем это число в переменную length. Следом мы объявляем функцию, которая будет вызываться на каждое нажатие любой клавиши, а внутри этой функции проверяем нажатую клавишу. Если это "вверх" или "вниз", то меняем highlightedIndex соответствующим образом. Length нам нужен, что бы при достижении последнего элемента клавиша вниз перескакивала на первый элемент, а при достижении первого, клавиша вверх - на последний.

Всё бы ничего, но в настоящий момент выделяется каждый пункт меню - даже заблокированный, даже MenuHeader (если бы он у нас был). Это не порядок, поэтому давайте учтём это. Простейший способ, который пришёл мне в голову - это держать массив, в котором мы будем хранить только валидные индексы и highlightedIndex будет означать позицию элемента в этом массиве, а не позицию элемента в целом:

const [highlightedIndex, setHighlightedIndex] = useState(-1);
const items = useMemo(() => Children.toArray(children), [children]);

const indexes = useMemo(() => (
  items.reduce<Array<number>>((result, item, index) => {
    if (React.isValidElement(item)) {
      if (!item.props.disabled && item.type === MenuItem) {
        result.push(index)
      }
    }
    
    return result;
  }, [])
), [items]);

...

const handleKeyDown = (ev: KeyboardEvent) => {
  switch (ev.code) {
    case 'ArrowDown':
      ev.preventDefault();
      ev.stopPropagation();
      setHighlightedIndex(highlightedIndex => {
        const index = highlightedIndex === indexes.length - 1 ? 0 : highlightedIndex + 1;
        return index;
      });
      break;
    case 'ArrowUp': {
      ev.preventDefault();
      ev.stopPropagation();
      setHighlightedIndex(highlightedIndex => {
        const index = highlightedIndex === 0 ? indexes.length - 1 : highlightedIndex - 1;
        return index;
      });
      break;
    }
  }
};

...


return (
  ...
  <Menu>
    {
      Children.map(children, (child, index) => {
        if (isValidElement(child)) {
          return cloneElement(child, {
            active: index === indexes[highlightedIndex],
            onMouseEnter: () => setHighlightedIndex(indexes.indexOf(index)),
          });
        }
      })
    }
  </Menu>
  ...
)

Первым делом мы формируем массив индексов. Если элемент является валидным React элементом, то мы проверяем, не заблокирован ли данный компонент и является ли он экземпляром компонента MenuItem (на случай, если мы решим добавить MenuHeader или другие элементы в список). Если на оба вопроса ответ "да", то добавляем его индекс в массив индексов. В handleKeyDown почти ничего не поменялось. Мы теперь сравниваем higlightedIndex не с количеством элементов всего, а с количеством валидных элементов. Ну и последний элемент в списке - это теперь последний элемент в массиве индексов, а не последний элемент в children. Последняя модификация в методе onMouseEnter. В нём мы ищем индекс в массиве индексов.

Попробуйте компонент в действии сейчас - навигация стрелками должна избегать лишних элементов. Идеально!

Есть ещё один нюанс, который стоит учесть - если пунктов в меню будет очень много, то появится скролл, но сфокусированные элементы не будут видны пользователю, так как они будут выбираться за границами меню. К счастью, мы предусмотрительно прокидываем ref для каждого MenuItem, а следовательно можем обратиться к нему и проскроллить меню. 

...

const [highlightedIndex, setHighlightedIndex] = useState(-1);
const elements = useRef<Record<number, HTMLDivElement>>({});

...

return (
  ...
  <Menu>
    {
      Children.map(children, (child, index) => {
        if (isValidElement(child)) {
          return cloneElement(child, {
            active: index === indexes[highlightedIndex],
            onMouseEnter: () => setHighlightedIndex(indexes.indexOf(index)),
		     ref: (node: HTMLDivElement) => {
              elements.current[index] = node;
            }
          });
        }
      })
    }
  </Menu>
  ...
)

Мы создаем ref, в котором будем хранить список наших элементов в обычном объекте, где ключ - это индекс элемента, а значение - сам элемент. Теперь осталось только настроить скроллинг. Делать мы это будем по нажатию на клавиши вверх и вниз:

elements.current[indexes[index]]?.scrollIntoView({
  block: 'nearest',
});

Перейдем к главному в Dropdown - выбору элемента. Для этого у нас будет два способа - нажатие клавиши Enter при выделенном пункте и клик по нему мышкой:

const handleChange = (item: any) => {
  onChange(item);
  setOpen(false);
}

...

const handleKeyDown = async (ev: KeyboardEvent) => {
  switch (ev.code) {
    ...
    case 'Enter': {
      ev.preventDefault();
      ev.stopPropagation();
      const item = items[indexes[highlightedIndex]];
      if (highlightedIndex !== -1 && isValidElement(item)) {
        handleChange(item.props.value);
      }
      break;
    }
  }
}

...

return (
  <Menu>
    ...
      onMouseEnter: () => setHighlightedIndex(indexes.indexOf(index)),
      onClick: (ev: MouseEvent) => {
        ev.stopPropagation();
        handleChange(child.props.value);
      }
    ...
  </Menu>
)

Добавим вывод выбранного элемента в консоль для простоты дебага:

<Dropdown label='Dropdown' onChange={item => console.log(item)}>

Типизация Dropdown

Если вы наведёте курсор на item внутри onChange, то увидите, что его тип равен any - не очень удобно. Мы можем сделать для Dropdown пропы, которые будут дженериком и будут принимать тип элемента. Таким образом мы затипизируем Dropdown и наш onChange коллбэк будет знать, с каким типом объекта имеет дело.

Типизация компонента делается очень легко:

type Props<TItem = any> = {
  label: ReactNode;
  onChange(item: TItem): void;
};

export const Dropdown = <T extends unknown>(props: PropsWithChildren<Props<T>>) => {

Теперь прокинем используемый нами тип в компонент в месте его использования:

type Item = {
  label: string;
  value: number;
  disabled?: boolean;
}

<Dropdown<Item> label='Dropdown' onChange={item => console.log(item)}>

Мультивыбор

Предлагаю теперь научить наш Dropdown позволять выбирать несколько элементов. Что бы всем этим было удобно пользоваться, воспользуемся discriminated unions. Суть в следующем - когда пользователь явно указывает, что хочет от Dropdown получить multiselect поведение, мы меняем сигнатуру коллбэка onChange таким образом, что бы он принимал не просто элемент, а массив элементов. Делается это следующим образом:

enum Behaviour {
  SINGLE,
  MULTIPLE,
}

type CommonProps<TItem = any> = {
  view: ReactNode
};

type SingleProps<TItem = any> = {
  behaviour: Behaviour.SINGLE;
  value: TItem;
  onChange(item: TItem): void;
} & CommonProps<TItem>;

type MultipleProps<TItem = any> = {
  behaviour: Behaviour.MULTIPLE;
  value: TItem[];
  onChange(items: TItem[]): void;
} & CommonProps<TItem>;

type Props<TItem> = SingleProps<TItem> | MultipleProps<TItem>;

Мы создаем enum, в котором перечисляем возможные варианты (подойдет и строковый литерал), затем объявляем общие пропы, пропы для обычного селекта и пропы для селекта с мультивыбором. В последних двух явно указываем behaviour и соответствующие варианты onChange и value.

Теперь внутри Dropdown будем определять текущее поведение и в зависимости от него вызывать onChange с разными значениями:

const handleChange = (item: T) => {
  switch (props.behaviour) {
    case Behaviour.SINGLE: {
      props.onChange(item);
      setOpen(false);
      break;
    }
    case Behaviour.MULTIPLE: {
      props.value.includes(item)
        ? props.onChange(props.value.filter(value => value !== item))
        : props.onChange([...props.value, item]);
      break;
    }
  }
}

Обратите внимание! Мы не деструктуризируем behaviour, value и onChange, а достаём их напрямую из props. В противном случае пропадёт связь между ними и TypeScript не поймет, в каком случае какое из них принимает какой тип.

Порталирование

Если мы поместим текущую версию Dropdown в модальное окно с overflow: hidden , то отчётливо увидим, что к такому повороту наш компонент не готов - содержимое меню будет обрезаться границами модалки. Для предотвращения такой ситуации в React предусмотрен механизм порталирования - меню будет отрендерено в DOM-ноде, которая будет находиться за пределами окна и, следовательно, не будет ограничена его контейнером:

import {createPortal} from 'react-dom';

...

{
  isOpen && createPortal(
    <Menu>
      ...
    </Menu>
    ,document.body
  )
}

К сожалению, портализация напрочь ломает позиционирование меню и нам придётся исправлять это руками:

type Coords = {
  left: number;
  top: number;
  width: number;
};

...

export const Dropdown = <T extends unknown>(props: PropsWithChildren<Props<T>>) => {
  const elements = useRef<Record<number, HTMLDivElement>>({});
  const controlRef = useRef<HTMLButtonElement>(null);
  const [coords, setCoords] = useState<Coords | null>(null);

  ...
  
  const getCoords = (): Coords | null => {
    const box = controlRef.current?.getBoundingClientRect();

    if (box) {
      return {
        left: box.left,
        top: box.top + box.height,
        width: box.width,
      };
    }

    return null;
  };

  useEffect(() => {
    if (!isOpen) return;

    const coords = getCoords();
    setCoords(coords);
  }, [isOpen]);

  
  return (
    <Root>
      <Control ref={controlRef} onClick={handleOpen} type='button'>{label}</Control>
      {
        isOpen && coords && createPortal(
          <Menu coords={coords}>
            ...
          </Menu>
        , document.body)
      }
    </Root>
  )

...

const Menu = styled.menu<{ coords: Coords }>`
  position: absolute;
  left: ${p => `${p.coords.left}px`};
  top: ${p => `${p.coords.top}px`};
  min-width: ${p => `${Math.max(150, p.coords.width)}px`};
  ...
`;

Сперва мы объявляем новый тип - Coords. В нём мы будем хранить текущие координаты меню. Затем нам надо завести переменную controlRef для нашего контрола и стейт для координат. Функция getCoords вычисляет эти самые координаты и возвращает null, если это сделать не удалось. Далее мы заводим новый useEffect (или используем предыдущий - это не принципиально) и в нём устанавливаем эти координаты, когда меню открыто. В return главное - не забыть добавить controlRef к контролу и добавить новое условие - показываем меню, если coords присутствуют (как вы помните, они у нас могут быть равны null). В стилях прописываем position: absolute и используем переданные координаты.

Закрытие меню

Давайте добавим последнюю маленькую деталь - будем закрывать модальное окно по клику за его пределами. Есть как минимум два способа это сделать. В первом мы слушаем каждый клик при открытом меню и с помощью Node.contains() проверяем, находится ли он внутри меню. Второй способ - располагать под меню div на всю ширину и высоту окна и при клику по нему закрывать меню. Мне больше по душе второй вариант, так как первый требует дополнительных ухищрений, если у нас появляются элементы помимо меню (такое бывало в моей практике).

Я покажу, как реализовать второй вариант, а руководство по реализации первого вы легко сможете найти в интернете по запросу click outside.

return (
  ...
  {
    isOpen && coords && createPortal(
      <>
        <Backdrop onClick={() => setOpen(false)} />
        <Menu coords={coords}>
          ...
        </Menu>
      </>
   }
)

const Backdrop = styled.div`
  position: fixed;
  inset: 0;
`;

Заключение

Это всё, что я хотел сегодня рассказать, но разработка идеального Dropdown на этом не заканчивается - осталось ещё много способов сделать его лучше. Можно добавить поле для фильтрации результатов, закрытие по Esc, дополнительные выпадающие меню, автоматическое позиционирование меню с помощью popper.js, you name it. Если у вас возникли какие-то трудности или вопросы - я буду рад ответить на них в комментариях.

Теги:
Хабы:
+1
Комментарии20

Публикации

Истории

Работа

Ближайшие события