Vizzuality/landgriffon

View on GitHub
client/src/components/forms/select/component.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import { Listbox, Transition } from '@headlessui/react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { flip, useFloating, size } from '@floating-ui/react-dom';
import { autoUpdate } from '@floating-ui/react';
import { isArray, sortBy } from 'lodash-es';

import Hint from '../hint';

import Loading from 'components/loading';
import Badge from 'components/badge';

import type { Option, SelectProps } from './types';

const Select = <T,>({
  value,
  defaultValue,
  error,
  icon,
  label,
  loading = false,
  options = [],
  placeholder = 'Select an option',
  showHint,
  onChange,
  multiple,
  theme = 'light',
  ...props
}: SelectProps<T>) => {
  const parentRef = useRef();
  const [selected, setSelected] = useState<Option<T> | Option<string> | Option<T>[]>(
    multiple ? [] : { label: '', value: '' },
  );
  const { x, y, refs, strategy } = useFloating<HTMLButtonElement>({
    middleware: [
      flip(),
      size({
        apply({ elements }) {
          const referenceWidth = elements.reference.getBoundingClientRect().width;
          const floatingWidth = elements.floating.getBoundingClientRect().width;

          Object.assign(elements.floating.style, {
            width: `${floatingWidth > referenceWidth ? floatingWidth : referenceWidth}px`,
          });
        },
      }),
    ],
    placement: 'bottom-start',
    whileElementsMounted: autoUpdate,
    strategy: 'fixed',
  });

  const rowVirtualizer = useVirtualizer({
    count: options.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 10,
  });

  const handleChange = useCallback(
    (current: Parameters<typeof onChange>[0]) => {
      onChange?.(current);
      setSelected(current);
    },
    [onChange],
  );

  const handleDelete = useCallback(
    (option: Option<T>) => {
      if (isArray(selected)) {
        const newSelected = selected.filter(({ value }) => option.value !== value);
        setSelected(newSelected);
        onChange?.(newSelected);
      }
    },
    [selected, onChange],
  );

  // ? in case the value is not set in the hook initialization, it will be set here after first render.
  useEffect(() => {
    if (defaultValue && !value) {
      setSelected(defaultValue);
    } else if (value) {
      setSelected(value);
    }
  }, [defaultValue, value]);

  const labelSelect = useMemo(() => {
    if (isArray(selected) && selected?.[0]?.label) {
      return (
        <div className="flex flex-wrap gap-2">
          {(sortBy(selected, ['label']) as Option<T>[] | Option<string>[]).map((option) => (
            <Badge
              key={`tree-select-badge-${option.value}`}
              removable
              onClick={() => handleDelete(option)}
              theme="big"
              className="text-xs"
            >
              {option.label}
            </Badge>
          ))}
        </div>
      );
    }

    if (!isArray(selected) && selected?.label) {
      return selected.label;
    }

    return (
      <span
        className={classnames('text-gray-500', {
          'inline-block pl-2': multiple,
        })}
      >
        {placeholder}
      </span>
    );
  }, [selected, multiple, placeholder, handleDelete]);

  return (
    <div data-testid={`select-${props.id || props.name || props['data-testid']}`}>
      <Listbox
        by="value"
        value={selected}
        onChange={handleChange}
        disabled={props.disabled || loading}
        multiple={multiple}
      >
        {({ open }) => (
          <>
            {!!label && (
              <Listbox.Label className="block text-sm font-medium text-gray-700">
                {label}
              </Listbox.Label>
            )}
            <Listbox.Button
              className={classnames(
                'relative inline-flex min-h-[42px] w-full cursor-default items-center rounded-md border text-left leading-5 shadow-sm hover:cursor-pointer focus:outline-none focus:ring-0 disabled:cursor-default disabled:bg-gray-300/20',
                {
                  'bg-white focus:border-navy-400': theme === 'light',
                  'bg-navy-400 text-white placeholder:text-white focus:bg-navy-600':
                    theme === 'dark',
                  'py-2.5 pl-3 pr-10': !multiple,
                  'py-1 pl-1 pr-10': multiple,
                  'mt-1': !!label,
                  'border-red-400': error,
                  'border-gray-200': theme === 'light' && !error,
                  'border-navy-400': theme === 'dark' && !error,
                },
              )}
              ref={refs.setReference}
            >
              {icon && <div className="mr-2">{cloneElement(icon)}</div>}
              <span className="block min-h-full truncate text-sm">{labelSelect}</span>
              <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
                {open && !loading && (
                  <ChevronUpIcon
                    className={classnames('h-4 w-4', {
                      'text-gray-300': props.disabled,
                      'text-gray-900': theme === 'light',
                      'text-white': theme === 'dark',
                    })}
                    aria-hidden="true"
                  />
                )}
                {!open && !loading && (
                  <ChevronDownIcon
                    className={classnames('h-4 w-4', {
                      'text-gray-300': props.disabled,
                      'text-gray-900': theme === 'light',
                      'text-white': theme === 'dark',
                    })}
                    aria-hidden="true"
                  />
                )}
                {loading && (
                  <Loading
                    className={classnames('h-4 w-4 text-navy-400', {
                      'text-navy-400': theme === 'light',
                      'text-white': theme === 'dark',
                    })}
                  />
                )}
              </span>
            </Listbox.Button>
            <Transition
              show={open}
              as="div"
              enter="transition ease-out duration-200"
              enterFrom="opacity-0 translate-y-1"
              enterTo="opacity-100 translate-y-0"
              leave="transition ease-in duration-150"
              leaveFrom="opacity-100 translate-y-0"
              leaveTo="opacity-0 translate-y-1"
              className="z-50"
              style={{
                position: strategy,
                top: y ?? 0,
                left: x ?? 0,
              }}
              ref={refs.setFloating}
            >
              <div className="mt-2 max-h-60 max-w-sm overflow-y-auto rounded-md bg-white text-base shadow-sm ring-1 ring-gray-200 focus:outline-none">
                <Listbox.Options
                  ref={parentRef}
                  style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
                  className="relative w-full"
                >
                  {rowVirtualizer.getVirtualItems().map((virtualItem) => {
                    const option = options[virtualItem.index];
                    return (
                      <Listbox.Option
                        key={virtualItem.index}
                        className={({ active, disabled }) =>
                          classnames(
                            'relative cursor-default select-none py-2 pl-3 pr-9 text-sm hover:cursor-pointer',
                            {
                              'bg-navy-50': active,
                              'pointer-events-none cursor-default': disabled,
                            },
                          )
                        }
                        style={{
                          height: `${virtualItem.size}px`,
                        }}
                        value={option}
                        disabled={option.disabled}
                        data-headlessui-state={option.disabled ? 'disabled' : 'active'}
                      >
                        {({ selected, disabled }) => (
                          <div
                            className={classnames('block truncate', {
                              'text-navy-400': selected,
                              'text-gray-300': disabled,
                              'text-gray-900': !selected && !disabled,
                            })}
                          >
                            {option.label}
                          </div>
                        )}
                      </Listbox.Option>
                    );
                  })}
                  {!options.length && (
                    <div className="px-3 py-2 text-sm text-gray-500">No results</div>
                  )}
                </Listbox.Options>
              </div>
            </Transition>

            {showHint && error && typeof error === 'string' && (
              <Hint data-testid={`hint-input-${props.name}`}>{error}</Hint>
            )}
          </>
        )}
      </Listbox>
    </div>
  );
};

Select.displayName = 'Select';

export default Select;