186 lines
		
	
	
		
			4.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			186 lines
		
	
	
		
			4.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
import { useCallback, useEffect, useRef, useState } from 'react';
 | 
						|
 | 
						|
import classNames from 'classnames';
 | 
						|
 | 
						|
import { supportsPassiveEvents } from 'detect-passive-events';
 | 
						|
 | 
						|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
 | 
						|
 | 
						|
import type { IconProp } from './icon';
 | 
						|
import { Icon } from './icon';
 | 
						|
 | 
						|
const listenerOptions = supportsPassiveEvents
 | 
						|
  ? { passive: true, capture: true }
 | 
						|
  : true;
 | 
						|
 | 
						|
export interface SelectItem {
 | 
						|
  value: string;
 | 
						|
  icon?: string;
 | 
						|
  iconComponent?: IconProp;
 | 
						|
  text: string;
 | 
						|
  meta: string;
 | 
						|
  extra?: string;
 | 
						|
}
 | 
						|
 | 
						|
interface Props {
 | 
						|
  value: string;
 | 
						|
  classNamePrefix: string;
 | 
						|
  style?: React.CSSProperties;
 | 
						|
  items: SelectItem[];
 | 
						|
  onChange: (value: string) => void;
 | 
						|
  onClose: () => void;
 | 
						|
}
 | 
						|
 | 
						|
export const DropdownSelector: React.FC<Props> = ({
 | 
						|
  style,
 | 
						|
  items,
 | 
						|
  value,
 | 
						|
  classNamePrefix = 'privacy-dropdown',
 | 
						|
  onClose,
 | 
						|
  onChange,
 | 
						|
}) => {
 | 
						|
  const nodeRef = useRef<HTMLUListElement>(null);
 | 
						|
  const focusedItemRef = useRef<HTMLLIElement>(null);
 | 
						|
  const [currentValue, setCurrentValue] = useState(value);
 | 
						|
 | 
						|
  const handleDocumentClick = useCallback(
 | 
						|
    (e: MouseEvent | TouchEvent) => {
 | 
						|
      if (
 | 
						|
        nodeRef.current &&
 | 
						|
        e.target instanceof Node &&
 | 
						|
        !nodeRef.current.contains(e.target)
 | 
						|
      ) {
 | 
						|
        onClose();
 | 
						|
        e.stopPropagation();
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [nodeRef, onClose],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleClick = useCallback(
 | 
						|
    (
 | 
						|
      e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
 | 
						|
    ) => {
 | 
						|
      const value = e.currentTarget.getAttribute('data-index');
 | 
						|
 | 
						|
      e.preventDefault();
 | 
						|
 | 
						|
      onClose();
 | 
						|
      if (value) onChange(value);
 | 
						|
    },
 | 
						|
    [onClose, onChange],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleKeyDown = useCallback(
 | 
						|
    (e: React.KeyboardEvent<HTMLLIElement>) => {
 | 
						|
      const value = e.currentTarget.getAttribute('data-index');
 | 
						|
      const index = items.findIndex((item) => item.value === value);
 | 
						|
 | 
						|
      let element: Element | null | undefined = null;
 | 
						|
 | 
						|
      switch (e.key) {
 | 
						|
        case 'Escape':
 | 
						|
          onClose();
 | 
						|
          break;
 | 
						|
        case ' ':
 | 
						|
        case 'Enter':
 | 
						|
          handleClick(e);
 | 
						|
          break;
 | 
						|
        case 'ArrowDown':
 | 
						|
          element =
 | 
						|
            nodeRef.current?.children[index + 1] ??
 | 
						|
            nodeRef.current?.firstElementChild;
 | 
						|
          break;
 | 
						|
        case 'ArrowUp':
 | 
						|
          element =
 | 
						|
            nodeRef.current?.children[index - 1] ??
 | 
						|
            nodeRef.current?.lastElementChild;
 | 
						|
          break;
 | 
						|
        case 'Tab':
 | 
						|
          if (e.shiftKey) {
 | 
						|
            element =
 | 
						|
              nodeRef.current?.children[index + 1] ??
 | 
						|
              nodeRef.current?.firstElementChild;
 | 
						|
          } else {
 | 
						|
            element =
 | 
						|
              nodeRef.current?.children[index - 1] ??
 | 
						|
              nodeRef.current?.lastElementChild;
 | 
						|
          }
 | 
						|
          break;
 | 
						|
        case 'Home':
 | 
						|
          element = nodeRef.current?.firstElementChild;
 | 
						|
          break;
 | 
						|
        case 'End':
 | 
						|
          element = nodeRef.current?.lastElementChild;
 | 
						|
          break;
 | 
						|
      }
 | 
						|
 | 
						|
      if (element && element instanceof HTMLElement) {
 | 
						|
        const selectedValue = element.getAttribute('data-index');
 | 
						|
        element.focus();
 | 
						|
        if (selectedValue) setCurrentValue(selectedValue);
 | 
						|
        e.preventDefault();
 | 
						|
        e.stopPropagation();
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [nodeRef, items, onClose, handleClick, setCurrentValue],
 | 
						|
  );
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    document.addEventListener('click', handleDocumentClick, { capture: true });
 | 
						|
    document.addEventListener('touchend', handleDocumentClick, listenerOptions);
 | 
						|
    focusedItemRef.current?.focus({ preventScroll: true });
 | 
						|
 | 
						|
    return () => {
 | 
						|
      document.removeEventListener('click', handleDocumentClick, {
 | 
						|
        capture: true,
 | 
						|
      });
 | 
						|
      document.removeEventListener(
 | 
						|
        'touchend',
 | 
						|
        handleDocumentClick,
 | 
						|
        listenerOptions,
 | 
						|
      );
 | 
						|
    };
 | 
						|
  }, [handleDocumentClick]);
 | 
						|
 | 
						|
  return (
 | 
						|
    <ul style={style} role='listbox' ref={nodeRef}>
 | 
						|
      {items.map((item) => (
 | 
						|
        <li
 | 
						|
          role='option'
 | 
						|
          tabIndex={0}
 | 
						|
          key={item.value}
 | 
						|
          data-index={item.value}
 | 
						|
          onKeyDown={handleKeyDown}
 | 
						|
          onClick={handleClick}
 | 
						|
          className={classNames(`${classNamePrefix}__option`, {
 | 
						|
            active: item.value === currentValue,
 | 
						|
          })}
 | 
						|
          aria-selected={item.value === currentValue}
 | 
						|
          ref={item.value === currentValue ? focusedItemRef : null}
 | 
						|
        >
 | 
						|
          {item.icon && item.iconComponent && (
 | 
						|
            <div className={`${classNamePrefix}__option__icon`}>
 | 
						|
              <Icon id={item.icon} icon={item.iconComponent} />
 | 
						|
            </div>
 | 
						|
          )}
 | 
						|
 | 
						|
          <div className={`${classNamePrefix}__option__content`}>
 | 
						|
            <strong>{item.text}</strong>
 | 
						|
            {item.meta}
 | 
						|
          </div>
 | 
						|
 | 
						|
          {item.extra && (
 | 
						|
            <div
 | 
						|
              className={`${classNamePrefix}__option__additional`}
 | 
						|
              title={item.extra}
 | 
						|
            >
 | 
						|
              <Icon id='info-circle' icon={InfoIcon} />
 | 
						|
            </div>
 | 
						|
          )}
 | 
						|
        </li>
 | 
						|
      ))}
 | 
						|
    </ul>
 | 
						|
  );
 | 
						|
};
 |