logo
eng-flag

Creating a Reusable Dropdown Component in React

In this guide, we'll walk through the process of creating a flexible and reusable dropdown component in React. We'll cover the basics, advanced features, and best practices for implementing a dropdown that can be easily integrated into various parts of your application.

Table of Contents

  1. Introduction
  2. Basic Dropdown Component
  3. Adding Customization Options
  4. Implementing Keyboard Navigation
  5. Handling Outside Clicks
  6. Adding Search Functionality
  7. Optimizing Performance
  8. Styling the Dropdown
  9. Advanced Features
  10. Testing the Dropdown Component
  11. Conclusion

Introduction

A dropdown component is a crucial UI element in many web applications. It allows users to select an option from a list, saving space and providing a clean interface. Creating a reusable dropdown component in React can significantly improve your development workflow and ensure consistency across your application.

Basic Dropdown Component

Let's start with a basic implementation of a dropdown component:

import React, { useState } from 'react';

const Dropdown = ({ options, onSelect }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedOption, setSelectedOption] = useState(null);

  const toggleDropdown = () => setIsOpen(!isOpen);

  const handleOptionClick = (option) => {
    setSelectedOption(option);
    setIsOpen(false);
    onSelect(option);
  };

  return (
    <div className="dropdown">
      <button onClick={toggleDropdown}>
        {selectedOption ? selectedOption.label : 'Select an option'}
      </button>
      {isOpen && (
        <ul className="dropdown-menu">
          {options.map((option) => (
            <li key={option.value} onClick={() => handleOptionClick(option)}>
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default Dropdown;

This basic implementation includes:

  • A toggle button to open/close the dropdown
  • A list of options rendered when the dropdown is open
  • Selection functionality to update the chosen option

Adding Customization Options

To make our dropdown more flexible, let's add some customization options:

import React, { useState } from 'react';
import PropTypes from 'prop-types';

const Dropdown = ({
  options,
  onSelect,
  placeholder = 'Select an option',
  disabled = false,
  width = '200px',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedOption, setSelectedOption] = useState(null);

  const toggleDropdown = () => {
    if (!disabled) {
      setIsOpen(!isOpen);
    }
  };

  const handleOptionClick = (option) => {
    setSelectedOption(option);
    setIsOpen(false);
    onSelect(option);
  };

  return (
    <div className="dropdown" style={{ width }}>
      <button onClick={toggleDropdown} disabled={disabled}>
        {selectedOption ? selectedOption.label : placeholder}
      </button>
      {isOpen && (
        <ul className="dropdown-menu">
          {options.map((option) => (
            <li key={option.value} onClick={() => handleOptionClick(option)}>
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

Dropdown.propTypes = {
  options: PropTypes.arrayOf(
    PropTypes.shape({
      value: PropTypes.string.isRequired,
      label: PropTypes.string.isRequired,
    })
  ).isRequired,
  onSelect: PropTypes.func.isRequired,
  placeholder: PropTypes.string,
  disabled: PropTypes.bool,
  width: PropTypes.string,
};

export default Dropdown;

We've added:

  • A customizable placeholder
  • A disabled state
  • Adjustable width
  • PropTypes for type checking

Implementing Keyboard Navigation

Accessibility is crucial for a good dropdown component. Let's add keyboard navigation:

import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';

const Dropdown = ({
  options,
  onSelect,
  placeholder = 'Select an option',
  disabled = false,
  width = '200px',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedOption, setSelectedOption] = useState(null);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const dropdownRef = useRef(null);

  const toggleDropdown = () => {
    if (!disabled) {
      setIsOpen(!isOpen);
      setHighlightedIndex(-1);
    }
  };

  const handleOptionClick = (option) => {
    setSelectedOption(option);
    setIsOpen(false);
    onSelect(option);
  };

  const handleKeyDown = (event) => {
    if (!isOpen) return;

    switch (event.key) {
      case 'ArrowDown':
        setHighlightedIndex((prevIndex) =>
          Math.min(prevIndex + 1, options.length - 1)
        );
        break;
      case 'ArrowUp':
        setHighlightedIndex((prevIndex) => Math.max(prevIndex - 1, 0));
        break;
      case 'Enter':
        if (highlightedIndex !== -1) {
          handleOptionClick(options[highlightedIndex]);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        break;
      default:
        break;
    }
  };

  useEffect(() => {
    if (isOpen) {
      dropdownRef.current?.focus();
    }
  }, [isOpen]);

  return (
    <div
      className="dropdown"
      style={{ width }}
      ref={dropdownRef}
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      <button onClick={toggleDropdown} disabled={disabled}>
        {selectedOption ? selectedOption.label : placeholder}
      </button>
      {isOpen && (
        <ul className="dropdown-menu">
          {options.map((option, index) => (
            <li
              key={option.value}
              onClick={() => handleOptionClick(option)}
              className={index === highlightedIndex ? 'highlighted' : ''}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

// PropTypes definition (same as before)

export default Dropdown;

This implementation adds:

  • Arrow key navigation
  • Enter key selection
  • Escape key to close the dropdown
  • Focus management

Handling Outside Clicks

To improve the user experience, let's close the dropdown when clicking outside:

import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';

const Dropdown = ({ /* ...props */ }) => {
  // ... existing state and functions

  useEffect(() => {
    const handleOutsideClick = (event) => {
      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleOutsideClick);

    return () => {
      document.removeEventListener('mousedown', handleOutsideClick);
    };
  }, []);

  // ... rest of the component
};

// PropTypes definition (same as before)

export default Dropdown;

This addition ensures that the dropdown closes when the user clicks outside of it.

Adding Search Functionality

For dropdowns with many options, a search feature can be very helpful:

import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';

const Dropdown = ({
  options,
  onSelect,
  placeholder = 'Select an option',
  disabled = false,
  width = '200px',
  searchable = false,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedOption, setSelectedOption] = useState(null);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const [searchTerm, setSearchTerm] = useState('');
  const dropdownRef = useRef(null);
  const searchInputRef = useRef(null);

  const toggleDropdown = () => {
    if (!disabled) {
      setIsOpen(!isOpen);
      setHighlightedIndex(-1);
      setSearchTerm('');
    }
  };

  const handleOptionClick = (option) => {
    setSelectedOption(option);
    setIsOpen(false);
    onSelect(option);
    setSearchTerm('');
  };

  const handleKeyDown = (event) => {
    // ... existing key handling logic
  };

  const handleSearchChange = (event) => {
    setSearchTerm(event.target.value);
    setHighlightedIndex(-1);
  };

  const filteredOptions = options.filter((option) =>
    option.label.toLowerCase().includes(searchTerm.toLowerCase())
  );

  useEffect(() => {
    if (isOpen && searchable) {
      searchInputRef.current?.focus();
    }
  }, [isOpen, searchable]);

  // ... existing outside click handling

  return (
    <div
      className="dropdown"
      style={{ width }}
      ref={dropdownRef}
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      <button onClick={toggleDropdown} disabled={disabled}>
        {selectedOption ? selectedOption.label : placeholder}
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          {searchable && (
            <input
              type="text"
              placeholder="Search..."
              value={searchTerm}
              onChange={handleSearchChange}
              ref={searchInputRef}
            />
          )}
          <ul>
            {filteredOptions.map((option, index) => (
              <li
                key={option.value}
                onClick={() => handleOptionClick(option)}
                className={index === highlightedIndex ? 'highlighted' : ''}
              >
                {option.label}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

Dropdown.propTypes = {
  // ... existing PropTypes
  searchable: PropTypes.bool,
};

export default Dropdown;

This implementation adds:

  • A searchable prop to enable/disable search functionality
  • A search input field when searchable is true
  • Filtering of options based on the search term

Optimizing Performance

For large lists of options, we can optimize performance using virtualization:

import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { FixedSizeList as List } from 'react-window';

const Dropdown = ({
  options,
  onSelect,
  placeholder = 'Select an option',
  disabled = false,
  width = '200px',
  searchable = false,
  virtualizedHeight = 300,
  itemHeight = 35,
}) => {
  // ... existing state and functions

  const renderOption = ({ index, style }) => {
    const option = filteredOptions[index];
    return (
      <li
        style={style}
        key={option.value}
        onClick={() => handleOptionClick(option)}
        className={index === highlightedIndex ? 'highlighted' : ''}
      >
        {option.label}
      </li>
    );
  };

  return (
    <div
      className="dropdown"
      style={{ width }}
      ref={dropdownRef}
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      <button onClick={toggleDropdown} disabled={disabled}>
        {selectedOption ? selectedOption.label : placeholder}
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          {searchable && (
            <input
              type="text"
              placeholder="Search..."
              value={searchTerm}
              onChange={handleSearchChange}
              ref={searchInputRef}
            />
          )}
          <List
            height={virtualizedHeight}
            itemCount={filteredOptions.length}
            itemSize={itemHeight}
            width={width}
          >
            {renderOption}
          </List>
        </div>
      )}
    </div>
  );
};

Dropdown.propTypes = {
  // ... existing PropTypes
  virtualizedHeight: PropTypes.number,
  itemHeight: PropTypes.number,
};

export default Dropdown;

This optimization uses react-window to render only the visible options, improving performance for large lists.

Styling the Dropdown

To make our dropdown visually appealing and consistent, let's add some CSS:

.dropdown {
  position: relative;
  font-family: Arial, sans-serif;
}

.dropdown button {
  width: 100%;
  padding: 10px;
  background-color: #f8f9fa;
  border: 1px solid #ced4da;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
}

.dropdown button:hover {
  background-color: #e9ecef;
}

.dropdown button:disabled {
  background-color: #e9ecef;
  cursor: not-allowed;
}

.dropdown-menu {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 1000;
  width: 100%;
  max-height: 300px;
  overflow-y: auto;
  background-color: #ffffff;
  border: 1px solid #ced4da;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.dropdown-menu input {
  width: calc(100% - 20px);
  margin: 10px;
  padding: 5px;
  border: 1px solid #ced4da;
  border-radius: 4px;
}

.dropdown-menu ul {
  list-style-type: none;
  padding: 0;
  margin: 0;
}

.dropdown-menu li {
  padding: 10px;
  cursor: pointer;
}

.dropdown-menu li:hover,
.dropdown-menu li.highlighted {
  background-color: #f8f9fa;
}

You can import this CSS file in your component or use a CSS-in-JS solution for better encapsulation.

Advanced Features

Let's add some advanced features to our dropdown:

  1. Multi-select functionality
  2. Custom option rendering
  3. Option grouping

Here's an updated version of our component with these features:

import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { FixedSizeList as List } from 'react-window';

const Dropdown = ({
  options,
  onSelect,
  placeholder = 'Select an option',
  disabled = false,
  width = '200px',
  searchable = false,
  virtualizedHeight = 300,
  itemHeight = 35,
  multiple = false,
  renderOption,
  groupBy,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedOptions, setSelectedOptions] = useState([]);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const [searchTerm, setSearchTerm] = useState('');
  const dropdownRef = useRef(null);
  const searchInputRef = useRef(null);

  const toggleDropdown = () => {
    if (!disabled) {
      setIsOpen(!isOpen);
      setHighlightedIndex(-1);
      setSearchTerm('');
    }
  };

  const handleOptionClick = (option) => {
    if (multiple) {
      setSelectedOptions((prevSelected) => {
        const isSelected = prevSelected.some((item) => item.value === option.value);
        if (isSelected) {
          return prevSelected.filter((item) => item.value !== option.value);
        } else {
          return [...prevSelected, option];
        }
      });
    } else {
      setSelectedOptions([option]);
      setIsOpen(false);
    }
    onSelect(multiple ? selectedOptions : option);
  };

  const handleKeyDown = (event) => {
    if (!isOpen) return;

    switch (event.key) {
      case 'ArrowDown':
        setHighlightedIndex((prevIndex) =>
          Math.min(prevIndex + 1, filteredOptions.length - 1)
        );
        break;
      case 'ArrowUp':
        setHighlightedIndex((prevIndex) => Math.max(prevIndex - 1, 0));
        break;
      case 'Enter':
        if (highlightedIndex !== -1) {
          handleOptionClick(filteredOptions[highlightedIndex]);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        break;
      default:
        break;
    }
  };

  const handleSearchChange = (event) => {
    setSearchTerm(event.target.value);
    setHighlightedIndex(-1);
  };

  const filteredOptions = options.filter((option) =>
    option.label.toLowerCase().includes(searchTerm.toLowerCase())
  );

  const groupedOptions = groupBy
    ? filteredOptions.reduce((acc, option) => {
        const groupKey = groupBy(option);
        if (!acc[groupKey]) {
          acc[groupKey] = [];
        }
        acc[groupKey].push(option);
        return acc;
      }, {})
    : null;

  useEffect(() => {
    if (isOpen && searchable) {
      searchInputRef.current?.focus();
    }
  }, [isOpen, searchable]);

  useEffect(() => {
    const handleOutsideClick = (event) => {
      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleOutsideClick);

    return () => {
      document.removeEventListener('mousedown', handleOutsideClick);
    };
  }, []);

  const renderOptionItem = ({ index, style }) => {
    const option = filteredOptions[index];
    const isSelected = selectedOptions.some((item) => item.value === option.value);

    return (
      <li
        style={style}
        key={option.value}
        onClick={() => handleOptionClick(option)}
        className={`${index === highlightedIndex ? 'highlighted' : ''} ${
          isSelected ? 'selected' : ''
        }`}
      >
        {renderOption ? renderOption(option, isSelected) : option.label}
      </li>
    );
  };

  const renderGroupedOptions = () => {
    return Object.entries(groupedOptions).map(([group, groupOptions]) => (
      <div key={group} className="option-group">
        <div className="group-label">{group}</div>
        <List
          height={Math.min(virtualizedHeight, groupOptions.length * itemHeight)}
          itemCount={groupOptions.length}
          itemSize={itemHeight}
          width={width}
        >
          {({ index, style }) => {
            const option = groupOptions[index];
            const isSelected = selectedOptions.some((item) => item.value === option.value);
            return (
              <li
                style={style}
                key={option.value}
                onClick={() => handleOptionClick(option)}
                className={`${index === highlightedIndex ? 'highlighted' : ''} ${
                  isSelected ? 'selected' : ''
                }`}
              >
                {renderOption ? renderOption(option, isSelected) : option.label}
              </li>
            );
          }}
        </List>
      </div>
    ));
  };

  return (
    <div
      className="dropdown"
      style={{ width }}
      ref={dropdownRef}
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      <button onClick={toggleDropdown} disabled={disabled}>
        {selectedOptions.length > 0
          ? multiple
            ? `${selectedOptions.length} selected`
            : selectedOptions[0].label
          : placeholder}
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          {searchable && (
            <input
              type="text"
              placeholder="Search..."
              value={searchTerm}
              onChange={handleSearchChange}
              ref={searchInputRef}
            />
          )}
          {groupBy ? (
            renderGroupedOptions()
          ) : (
            <List
              height={virtualizedHeight}
              itemCount={filteredOptions.length}
              itemSize={itemHeight}
              width={width}
            >
              {renderOptionItem}
            </List>
          )}
        </div>
      )}
    </div>
  );
};

Dropdown.propTypes = {
  options: PropTypes.arrayOf(
    PropTypes.shape({
      value: PropTypes.string.isRequired,
      label: PropTypes.string.isRequired,
    })
  ).isRequired,
  onSelect: PropTypes.func.isRequired,
  placeholder: PropTypes.string,
  disabled: PropTypes.bool,
  width: PropTypes.string,
  searchable: PropTypes.bool,
  virtualizedHeight: PropTypes.number,
  itemHeight: PropTypes.number,
  multiple: PropTypes.bool,
  renderOption: PropTypes.func,
  groupBy: PropTypes.func,
};

export default Dropdown;

This updated version includes:

  1. Multi-select functionality:

    • The multiple prop allows selecting multiple options.
    • Selected options are stored in an array.
    • The button text shows the number of selected items when multiple is true.
  2. Custom option rendering:

    • The renderOption prop allows customizing how each option is displayed.
    • It receives the option object and the selection state as arguments.
  3. Option grouping:

    • The groupBy prop is a function that determines how options should be grouped.
    • Options are rendered in groups when groupBy is provided.

To use these new features, you can implement the dropdown like this:

import React from 'react';
import Dropdown from './Dropdown';

const App = () => {
  const options = [
    { value: 'fruit1', label: 'Apple', category: 'Fruits' },
    { value: 'fruit2', label: 'Banana', category: 'Fruits' },
    { value: 'vegetable1', label: 'Carrot', category: 'Vegetables' },
    { value: 'vegetable2', label: 'Broccoli', category: 'Vegetables' },
  ];

  const handleSelect = (selected) => {
    console.log('Selected:', selected);
  };

  const customRenderOption = (option, isSelected) => (
    <div>
      {isSelected ? '✓ ' : ''}
      {option.label} ({option.category})
    </div>
  );

  const groupByCategory = (option) => option.category;

  return (
    <Dropdown
      options={options}
      onSelect={handleSelect}
      placeholder="Select items"
      searchable
      multiple
      renderOption={customRenderOption}
      groupBy={groupByCategory}
    />
  );
};

export default App;

Testing the Dropdown Component

To ensure the reliability of our dropdown component, we should write unit tests. Here's an example using Jest and React Testing Library:

import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import Dropdown from './Dropdown';

const options = [
  { value: 'option1', label: 'Option 1' },
  { value: 'option2', label: 'Option 2' },
  { value: 'option3', label: 'Option 3' },
];

describe('Dropdown', () => {
  test('renders with placeholder', () => {
    render(<Dropdown options={options} onSelect={() => {}} placeholder="Select an option" />);
    expect(screen.getByText('Select an option')).toBeInTheDocument();
  });

  test('opens dropdown on click', () => {
    render(<Dropdown options={options} onSelect={() => {}} />);
    fireEvent.click(screen.getByRole('button'));
    expect(screen.getByText('Option 1')).toBeInTheDocument();
    expect(screen.getByText('Option 2')).toBeInTheDocument();
    expect(screen.getByText('Option 3')).toBeInTheDocument();
  });

  test('selects an option', () => {
    const handleSelect = jest.fn();
    render(<Dropdown options={options} onSelect={handleSelect} />);
    fireEvent.click(screen.getByRole('button'));
    fireEvent.click(screen.getByText('Option 2'));
    expect(handleSelect).toHaveBeenCalledWith({ value: 'option2', label: 'Option 2' });
    expect(screen.getByText('Option 2')).toBeInTheDocument();
  });

  test('filters options when searching', () => {
    render(<Dropdown options={options} onSelect={() => {}} searchable />);
    fireEvent.click(screen.getByRole('button'));
    fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'Option 1' } });
    expect(screen.getByText('Option 1')).toBeInTheDocument();
    expect(screen.queryByText('Option 2')).not.toBeInTheDocument();
    expect(screen.queryByText('Option 3')).not.toBeInTheDocument();
  });

  test('supports multiple selection', () => {
    const handleSelect = jest.fn();
    render(<Dropdown options={options} onSelect={handleSelect} multiple />);
    fireEvent.click(screen.getByRole('button'));
    fireEvent.click(screen.getByText('Option 1'));
    fireEvent.click(screen.getByText('Option 2'));
    expect(handleSelect).toHaveBeenCalledTimes(2);
    expect(screen.getByText('2 selected')).toBeInTheDocument();
  });
});

These tests cover the basic functionality of our dropdown component. You should add more tests to cover all the features and edge cases.

Conclusion

We've created a highly customizable and feature-rich dropdown component in React. This component includes:

  • Basic selection functionality
  • Keyboard navigation
  • Search capability
  • Performance optimization with virtualization
  • Multi-select support
  • Custom option rendering
  • Option grouping

2024 © All rights reserved - buraxta.com