Создание 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. Если у вас возникли какие-то трудности или вопросы - я буду рад ответить на них в комментариях.

Комментарии (20)


  1. antsam
    08.12.2021 21:25
    +3

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


    1. edencore Автор
      08.12.2021 22:19

      Я с этой дилеммой сталкивался на своем проекте и решил всё таки делать объединенный dropdown. Этого позволяет избежать дублирования кода, так как общих элементов у них очень много.


      1. antsam
        08.12.2021 23:09

        Общие элементы можно вынести и переиспользовать их в этих двух компонентах. И скорее всего эти общие части понадабятся еще в других комонентах (например DatePicker, ColorPicker). Но зато в пропсах будет все однозначно с какими данными работает компонент.


    1. nin-jin
      09.12.2021 02:33
      +2

      У Реакта и особенно StyledComponet всё очень плохо с декомпозицией. Там сложно сделать обобщённое, но легко кастомизируемое решение, не наворотив комбайн. Да даже тут в статье просто горы кода для такой, казалось бы, простой задачи.



  1. snobit
    09.12.2021 09:23

    onMouseEnter для подсветки элементов, серьезно? А потом удивляемся, почему веб тормозит...

    props.onChange(props.value.filter((value) => value !== item))
    не пробовали использовать Set?


    1. mayorovp
      09.12.2021 11:09

      Предлагаете передавать в props.onChange каждый раз один и тот же объект? Это шаг к тому, чтобы запутаться в ссылках на объекты...


      Если уж оптимизировать в эту сторону — надо начать с создания ObservableArray/ObservableSet, чтобы мутабельный объект и подписка на его изменения стали едины.


      1. snobit
        09.12.2021 11:40

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

        Кто-то посмотрит, сделает также, один-два компонента норм, но после десятка таких, все будет тормозить. И виноват в этом будет JS и React.


        1. mayorovp
          09.12.2021 11:50
          +1

          Ну, про onMouseEnter я и не спорил, это перебор, особенно уж на каждом элементе.


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


          1. snobit
            09.12.2021 12:05

            По-моему опыту 90% случает хватает простейшего лейаута

            <DropdownContainer> - position: relative
                <DropdownList> - position: absolute
                    {content}
                </DropdownList>
            </DropdownContainer>

            Но если нужен портал, надо понимать, что придется отслеживать коррдинаты и размеры родителя, а то случиться конфуз


            1. antsam
              09.12.2021 16:39
              +3

              А в оставшихся 10% случаях что делать? :)

              Самое рабочее решение - это портал, который работает в 100% случаев.
              И расчитывать координаты в момент отображения.
              И прятать в момент ресайза/скрола - иначе прийдется вдаваться в филосовские вопросы кто же должен быть выше - заголовок, сайдбар меню или выпадающий список.


  1. radist2s
    09.12.2021 11:21

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


  1. metalidea
    09.12.2021 13:26

    А потом в проекте 3 версии дропдауна))

    Используйте уже готовые компоненты, а еще лучше библиотеки компонентов и не нужно будет придумывать велосипед.


    1. antsam
      09.12.2021 17:03
      +2

      Вот как раз с библиотеками компонентов тут наоборот.

      Если используется библиотечный компонент, то как правило он что-то да не умеет из того что нужно именно в этом проекте, и тогда наварачиваются костыли поверх него или находится другая библиотека но старый компонент не поменяешь, потому как пропсы разные - в итоге в проекте два (и более) одинаковых компонентов.
      Свой же велосипед можно модифицировать и наварачивать сколь угодно долго не меняя при этом интерфейс его использования.
      Основной же недостаток библиотек
      1. то что они универсальны - добавляет тормознутости. Как правило в проекте нужна небольшая часть от всего функционала готового компонента.
      2. то что они уже содержат стили - добавляет ненужный код в проект. Как правило в конкретном проекте нужны свои стили и стили библиотечного компонента просто напросто висят мертвым грузом.

      Но я не за то чтобы писать все с нуля. Есть библиотеки из которых можно собрать компонент минимальными усилиями.


  1. kreddos
    09.12.2021 20:48

    Типизация Dropdown

    мне кажется у вас слишком усложнено, у вас такой компонент, что приходится делать так

    Придется явно указывать тип, как в вашем примере

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

    Но можно написать например так, и тут не придется мапить items, так как маппер передается и плюс тс сам поймет какие вы данные передали и высчитает тип

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

    это чисто мое ИМХО


    1. kreddos
      09.12.2021 20:55
      -1

      Забыл написать, в вашем случае дженерики вообще не нужны так как можно просто сделать так

      указать тип в onChange


    1. LEXA_JA
      09.12.2021 23:47
      -1

      Я бы не советал так делать в переиспользуемом компоненте, потому что вечно вылезают всякие дизайнерские выверты. Например нужно добавить полу для поиска, сгруппировать некоторые айтемы, добавить на каждую подгруппу заголовок, добавить разделители, на некоторые пункты меню добавить иконки, раскрасить, disabled состояние, вложенные дропдауны, меню айтем с подтверждением через диалог и т.д.
      Всё это не предусмотришь в базовом компоненте, если не превратить его в монструозный комбаин, поэтомы лучше прокидывать через children.


      1. kreddos
        10.12.2021 00:16
        -1

        Отчасти вы правы, но осоновная мысль, что дженерик тут лишний


      1. noodles
        11.12.2021 13:47

        потому что вечно вылезают всякие дизайнерские выверты

        Разбудите меня через 20 лет, а мы - фронтендеры всё так же будем бороться и делать кастомные дропдауны..)


  1. Alexandroppolus
    10.12.2021 11:31
    +1

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

    Если был перерендер при открытом дропдауне (например, что-то догрузилось), handleKeyDown будет устаревшим, то есть в его замыкании будет уже неактуальный набор items и indexes. Кейс относительно редкий, но по "закону Мерфи" его вероятность равна 100%. В зависимости эффекта надо бы добавить handleKeyDown, от греха. А саму функцию - в useCallback, чтобы не дергать эффект слишком часто (хотя это необязательно).

    А вообще, зашивать сюда работу с "итемами" неправильно. Дропдавн задуман в общем виде, с передачей произвольного children в выпадушку. Значит он не должен соваться в children. Его задача - обеспечить правильную работу портальной выпадушки. А далее его можно будет скомбинировать с календарем/селектом/etc, и получить несколько выпадушечных компонентов на одном переиспользованном механизме.