antoncoding/monarch

View on GitHub
app/markets/components/AssetFilter.tsx

Summary

Maintainability
Test Coverage
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons';
import Image from 'next/image';
import { ERC20Token, infoToKey } from '@/utils/tokens';

type FilterProps = {
  label: string;
  placeholder: string;
  selectedAssets: string[];
  setSelectedAssets: (assets: string[]) => void;
  items: ERC20Token[];
  loading?: boolean; // Made optional since it's not used
};

export default function AssetFilter({
  label,
  placeholder,
  selectedAssets,
  setSelectedAssets,
  items,
}: FilterProps) {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

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

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

  const selectOption = (token: ERC20Token) => {
    const tokenKey = token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|');
    if (selectedAssets.includes(tokenKey)) {
      setSelectedAssets(selectedAssets.filter((asset) => asset !== tokenKey));
    } else {
      setSelectedAssets([...selectedAssets, tokenKey]);
    }
    setQuery('');
  };

  const clearSelection = () => {
    setSelectedAssets([]);
    setQuery('');
    setIsOpen(false);
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Enter' || event.key === ' ') {
      toggleDropdown();
    }
  };

  const filteredItems = items.filter((token) =>
    token.symbol.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <div className="relative w-full" ref={dropdownRef}>
      <div
        className={`min-w-48 cursor-pointer rounded-sm bg-secondary p-2 shadow-sm transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700 ${
          isOpen ? 'bg-secondary-dark' : ''
        }`}
        role="button"
        tabIndex={0}
        onClick={toggleDropdown}
        onKeyDown={handleKeyDown}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
      >
        <span className="absolute left-2 top-2 px-1 text-xs">{label}</span>
        <div className="flex items-center justify-between pt-4">
          {selectedAssets.length > 0 ? (
            <div className="flex-scroll flex gap-2 p-1 pb-[2px]">
              {selectedAssets.map((asset) => {
                const token = items.find(
                  (item) =>
                    item.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|') === asset,
                );
                return token?.img ? (
                  <Image key={asset} src={token.img} alt={token.symbol} width={18} height={18} />
                ) : null;
              })}
            </div>
          ) : (
            <span className="p-[2px] text-sm text-gray-400">{placeholder}</span>
          )}
          <span className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}>
            <ChevronDownIcon />
          </span>
        </div>
      </div>
      {isOpen && (
        <div className="absolute z-10 mt-1 w-full rounded-sm bg-secondary shadow-lg">
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search tokens..."
            className="w-full border-none bg-transparent p-3 text-sm focus:outline-none"
          />
          <div className="relative">
            <ul className="custom-scrollbar max-h-60 overflow-auto pb-12" role="listbox">
              {filteredItems.map((token) => (
                <li
                  key={token.symbol}
                  className={`m-2 flex cursor-pointer items-center justify-between rounded-md p-2 text-sm hover:bg-gray-300 dark:hover:bg-gray-700 ${
                    selectedAssets.includes(
                      token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|'),
                    )
                      ? 'bg-gray-300 dark:bg-gray-700'
                      : ''
                  }`}
                  onClick={() => selectOption(token)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      selectOption(token);
                    }
                  }}
                  role="option"
                  aria-selected={selectedAssets.includes(
                    token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|'),
                  )}
                  tabIndex={0}
                >
                  <span>{token.symbol}</span>
                  {token.img && <Image src={token.img} alt={token.symbol} width={18} height={18} />}
                </li>
              ))}
            </ul>
            <div className="absolute bottom-0 left-0 right-0 border-gray-700 bg-secondary p-2">
              <button
                className="flex w-full items-center justify-between rounded-sm p-2 text-left text-xs text-secondary hover:bg-primary"
                onClick={clearSelection}
                type="button"
              >
                <span>Clear All</span>
                <TrashIcon className="h-5 w-5" />
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}