531 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			531 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {
 | 
						|
  useState,
 | 
						|
  useEffect,
 | 
						|
  useRef,
 | 
						|
  useCallback,
 | 
						|
  cloneElement,
 | 
						|
  Children,
 | 
						|
} from 'react';
 | 
						|
 | 
						|
import classNames from 'classnames';
 | 
						|
import { Link } from 'react-router-dom';
 | 
						|
 | 
						|
import type { Map as ImmutableMap } from 'immutable';
 | 
						|
 | 
						|
import Overlay from 'react-overlays/Overlay';
 | 
						|
import type {
 | 
						|
  OffsetValue,
 | 
						|
  UsePopperOptions,
 | 
						|
} from 'react-overlays/esm/usePopper';
 | 
						|
 | 
						|
import { fetchRelationships } from 'mastodon/actions/accounts';
 | 
						|
import {
 | 
						|
  openDropdownMenu,
 | 
						|
  closeDropdownMenu,
 | 
						|
} from 'mastodon/actions/dropdown_menu';
 | 
						|
import { openModal, closeModal } from 'mastodon/actions/modal';
 | 
						|
import { CircularProgress } from 'mastodon/components/circular_progress';
 | 
						|
import { isUserTouching } from 'mastodon/is_mobile';
 | 
						|
import {
 | 
						|
  isMenuItem,
 | 
						|
  isActionItem,
 | 
						|
  isExternalLinkItem,
 | 
						|
} from 'mastodon/models/dropdown_menu';
 | 
						|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
 | 
						|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
 | 
						|
 | 
						|
import type { IconProp } from './icon';
 | 
						|
import { IconButton } from './icon_button';
 | 
						|
 | 
						|
let id = 0;
 | 
						|
 | 
						|
type RenderItemFn<Item = MenuItem> = (
 | 
						|
  item: Item,
 | 
						|
  index: number,
 | 
						|
  handlers: {
 | 
						|
    onClick: (e: React.MouseEvent) => void;
 | 
						|
    onKeyUp: (e: React.KeyboardEvent) => void;
 | 
						|
  },
 | 
						|
) => React.ReactNode;
 | 
						|
 | 
						|
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
 | 
						|
 | 
						|
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
 | 
						|
 | 
						|
interface DropdownMenuProps<Item = MenuItem> {
 | 
						|
  items?: Item[];
 | 
						|
  loading?: boolean;
 | 
						|
  scrollable?: boolean;
 | 
						|
  onClose: () => void;
 | 
						|
  openedViaKeyboard: boolean;
 | 
						|
  renderItem?: RenderItemFn<Item>;
 | 
						|
  renderHeader?: RenderHeaderFn<Item>;
 | 
						|
  onItemClick?: ItemClickFn<Item>;
 | 
						|
}
 | 
						|
 | 
						|
export const DropdownMenu = <Item = MenuItem,>({
 | 
						|
  items,
 | 
						|
  loading,
 | 
						|
  scrollable,
 | 
						|
  onClose,
 | 
						|
  openedViaKeyboard,
 | 
						|
  renderItem,
 | 
						|
  renderHeader,
 | 
						|
  onItemClick,
 | 
						|
}: DropdownMenuProps<Item>) => {
 | 
						|
  const nodeRef = useRef<HTMLDivElement>(null);
 | 
						|
  const focusedItemRef = useRef<HTMLElement | null>(null);
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    const handleDocumentClick = (e: MouseEvent) => {
 | 
						|
      if (
 | 
						|
        e.target instanceof Node &&
 | 
						|
        nodeRef.current &&
 | 
						|
        !nodeRef.current.contains(e.target)
 | 
						|
      ) {
 | 
						|
        onClose();
 | 
						|
        e.stopPropagation();
 | 
						|
        e.preventDefault();
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    const handleKeyDown = (e: KeyboardEvent) => {
 | 
						|
      if (!nodeRef.current) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
 | 
						|
      const index = document.activeElement
 | 
						|
        ? items.indexOf(document.activeElement)
 | 
						|
        : -1;
 | 
						|
 | 
						|
      let element: Element | undefined;
 | 
						|
 | 
						|
      switch (e.key) {
 | 
						|
        case 'ArrowDown':
 | 
						|
          element = items[index + 1] ?? items[0];
 | 
						|
          break;
 | 
						|
        case 'ArrowUp':
 | 
						|
          element = items[index - 1] ?? items[items.length - 1];
 | 
						|
          break;
 | 
						|
        case 'Tab':
 | 
						|
          if (e.shiftKey) {
 | 
						|
            element = items[index - 1] ?? items[items.length - 1];
 | 
						|
          } else {
 | 
						|
            element = items[index + 1] ?? items[0];
 | 
						|
          }
 | 
						|
          break;
 | 
						|
        case 'Home':
 | 
						|
          element = items[0];
 | 
						|
          break;
 | 
						|
        case 'End':
 | 
						|
          element = items[items.length - 1];
 | 
						|
          break;
 | 
						|
        case 'Escape':
 | 
						|
          onClose();
 | 
						|
          break;
 | 
						|
      }
 | 
						|
 | 
						|
      if (element && element instanceof HTMLElement) {
 | 
						|
        element.focus();
 | 
						|
        e.preventDefault();
 | 
						|
        e.stopPropagation();
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    document.addEventListener('click', handleDocumentClick, { capture: true });
 | 
						|
    document.addEventListener('keydown', handleKeyDown, { capture: true });
 | 
						|
 | 
						|
    if (focusedItemRef.current && openedViaKeyboard) {
 | 
						|
      focusedItemRef.current.focus({ preventScroll: true });
 | 
						|
    }
 | 
						|
 | 
						|
    return () => {
 | 
						|
      document.removeEventListener('click', handleDocumentClick, {
 | 
						|
        capture: true,
 | 
						|
      });
 | 
						|
      document.removeEventListener('keydown', handleKeyDown, { capture: true });
 | 
						|
    };
 | 
						|
  }, [onClose, openedViaKeyboard]);
 | 
						|
 | 
						|
  const handleFocusedItemRef = useCallback(
 | 
						|
    (c: HTMLAnchorElement | HTMLButtonElement | null) => {
 | 
						|
      focusedItemRef.current = c as HTMLElement;
 | 
						|
    },
 | 
						|
    [],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleItemClick = useCallback(
 | 
						|
    (e: React.MouseEvent | React.KeyboardEvent) => {
 | 
						|
      const i = Number(e.currentTarget.getAttribute('data-index'));
 | 
						|
      const item = items?.[i];
 | 
						|
 | 
						|
      onClose();
 | 
						|
 | 
						|
      if (!item) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (typeof onItemClick === 'function') {
 | 
						|
        e.preventDefault();
 | 
						|
        onItemClick(item, i);
 | 
						|
      } else if (isActionItem(item)) {
 | 
						|
        e.preventDefault();
 | 
						|
        item.action();
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [onClose, onItemClick, items],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleItemKeyUp = useCallback(
 | 
						|
    (e: React.KeyboardEvent) => {
 | 
						|
      if (e.key === 'Enter' || e.key === ' ') {
 | 
						|
        handleItemClick(e);
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [handleItemClick],
 | 
						|
  );
 | 
						|
 | 
						|
  const nativeRenderItem = (option: Item, i: number) => {
 | 
						|
    if (!isMenuItem(option)) {
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
 | 
						|
    if (option === null) {
 | 
						|
      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
 | 
						|
    }
 | 
						|
 | 
						|
    const { text, dangerous } = option;
 | 
						|
 | 
						|
    let element: React.ReactElement;
 | 
						|
 | 
						|
    if (isActionItem(option)) {
 | 
						|
      element = (
 | 
						|
        <button
 | 
						|
          ref={i === 0 ? handleFocusedItemRef : undefined}
 | 
						|
          onClick={handleItemClick}
 | 
						|
          onKeyUp={handleItemKeyUp}
 | 
						|
          data-index={i}
 | 
						|
        >
 | 
						|
          {text}
 | 
						|
        </button>
 | 
						|
      );
 | 
						|
    } else if (isExternalLinkItem(option)) {
 | 
						|
      element = (
 | 
						|
        <a
 | 
						|
          href={option.href}
 | 
						|
          target={option.target ?? '_target'}
 | 
						|
          data-method={option.method}
 | 
						|
          rel='noopener'
 | 
						|
          ref={i === 0 ? handleFocusedItemRef : undefined}
 | 
						|
          onClick={handleItemClick}
 | 
						|
          onKeyUp={handleItemKeyUp}
 | 
						|
          data-index={i}
 | 
						|
        >
 | 
						|
          {text}
 | 
						|
        </a>
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      element = (
 | 
						|
        <Link
 | 
						|
          to={option.to}
 | 
						|
          ref={i === 0 ? handleFocusedItemRef : undefined}
 | 
						|
          onClick={handleItemClick}
 | 
						|
          onKeyUp={handleItemKeyUp}
 | 
						|
          data-index={i}
 | 
						|
        >
 | 
						|
          {text}
 | 
						|
        </Link>
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return (
 | 
						|
      <li
 | 
						|
        className={classNames('dropdown-menu__item', {
 | 
						|
          'dropdown-menu__item--dangerous': dangerous,
 | 
						|
        })}
 | 
						|
        key={`${text}-${i}`}
 | 
						|
      >
 | 
						|
        {element}
 | 
						|
      </li>
 | 
						|
    );
 | 
						|
  };
 | 
						|
 | 
						|
  const renderItemMethod = renderItem ?? nativeRenderItem;
 | 
						|
 | 
						|
  return (
 | 
						|
    <div
 | 
						|
      className={classNames('dropdown-menu__container', {
 | 
						|
        'dropdown-menu__container--loading': loading,
 | 
						|
      })}
 | 
						|
      ref={nodeRef}
 | 
						|
    >
 | 
						|
      {(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
 | 
						|
 | 
						|
      {!loading && renderHeader && items && (
 | 
						|
        <div className='dropdown-menu__container__header'>
 | 
						|
          {renderHeader(items)}
 | 
						|
        </div>
 | 
						|
      )}
 | 
						|
 | 
						|
      {!loading && items && (
 | 
						|
        <ul
 | 
						|
          className={classNames('dropdown-menu__container__list', {
 | 
						|
            'dropdown-menu__container__list--scrollable': scrollable,
 | 
						|
          })}
 | 
						|
        >
 | 
						|
          {items.map((option, i) =>
 | 
						|
            renderItemMethod(option, i, {
 | 
						|
              onClick: handleItemClick,
 | 
						|
              onKeyUp: handleItemKeyUp,
 | 
						|
            }),
 | 
						|
          )}
 | 
						|
        </ul>
 | 
						|
      )}
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
};
 | 
						|
 | 
						|
interface DropdownProps<Item = MenuItem> {
 | 
						|
  children?: React.ReactElement;
 | 
						|
  icon?: string;
 | 
						|
  iconComponent?: IconProp;
 | 
						|
  items?: Item[];
 | 
						|
  loading?: boolean;
 | 
						|
  title?: string;
 | 
						|
  disabled?: boolean;
 | 
						|
  scrollable?: boolean;
 | 
						|
  scrollKey?: string;
 | 
						|
  status?: ImmutableMap<string, unknown>;
 | 
						|
  forceDropdown?: boolean;
 | 
						|
  renderItem?: RenderItemFn<Item>;
 | 
						|
  renderHeader?: RenderHeaderFn<Item>;
 | 
						|
  onOpen?: () => void;
 | 
						|
  onItemClick?: ItemClickFn<Item>;
 | 
						|
}
 | 
						|
 | 
						|
const offset = [5, 5] as OffsetValue;
 | 
						|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
 | 
						|
 | 
						|
export const Dropdown = <Item = MenuItem,>({
 | 
						|
  children,
 | 
						|
  icon,
 | 
						|
  iconComponent,
 | 
						|
  items,
 | 
						|
  loading,
 | 
						|
  title = 'Menu',
 | 
						|
  disabled,
 | 
						|
  scrollable,
 | 
						|
  status,
 | 
						|
  forceDropdown = false,
 | 
						|
  renderItem,
 | 
						|
  renderHeader,
 | 
						|
  onOpen,
 | 
						|
  onItemClick,
 | 
						|
  scrollKey,
 | 
						|
}: DropdownProps<Item>) => {
 | 
						|
  const dispatch = useAppDispatch();
 | 
						|
  const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
 | 
						|
  const openedViaKeyboard = useAppSelector(
 | 
						|
    (state) => state.dropdownMenu.keyboard,
 | 
						|
  );
 | 
						|
  const [currentId] = useState(id++);
 | 
						|
  const open = currentId === openDropdownId;
 | 
						|
  const activeElement = useRef<HTMLElement | null>(null);
 | 
						|
  const targetRef = useRef<HTMLButtonElement | null>(null);
 | 
						|
  const prefetchAccountId = status
 | 
						|
    ? status.getIn(['account', 'id'])
 | 
						|
    : undefined;
 | 
						|
 | 
						|
  const handleClose = useCallback(() => {
 | 
						|
    if (activeElement.current) {
 | 
						|
      activeElement.current.focus({ preventScroll: true });
 | 
						|
      activeElement.current = null;
 | 
						|
    }
 | 
						|
 | 
						|
    dispatch(
 | 
						|
      closeModal({
 | 
						|
        modalType: 'ACTIONS',
 | 
						|
        ignoreFocus: false,
 | 
						|
      }),
 | 
						|
    );
 | 
						|
 | 
						|
    dispatch(closeDropdownMenu({ id: currentId }));
 | 
						|
  }, [dispatch, currentId]);
 | 
						|
 | 
						|
  const handleItemClick = useCallback(
 | 
						|
    (e: React.MouseEvent | React.KeyboardEvent) => {
 | 
						|
      const i = Number(e.currentTarget.getAttribute('data-index'));
 | 
						|
      const item = items?.[i];
 | 
						|
 | 
						|
      handleClose();
 | 
						|
 | 
						|
      if (!item) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (typeof onItemClick === 'function') {
 | 
						|
        e.preventDefault();
 | 
						|
        onItemClick(item, i);
 | 
						|
      } else if (isActionItem(item)) {
 | 
						|
        e.preventDefault();
 | 
						|
        item.action();
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [handleClose, onItemClick, items],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleClick = useCallback(
 | 
						|
    (e: React.MouseEvent | React.KeyboardEvent) => {
 | 
						|
      const { type } = e;
 | 
						|
 | 
						|
      if (open) {
 | 
						|
        handleClose();
 | 
						|
      } else {
 | 
						|
        onOpen?.();
 | 
						|
 | 
						|
        if (prefetchAccountId) {
 | 
						|
          dispatch(fetchRelationships([prefetchAccountId]));
 | 
						|
        }
 | 
						|
 | 
						|
        if (isUserTouching() && !forceDropdown) {
 | 
						|
          dispatch(
 | 
						|
            openModal({
 | 
						|
              modalType: 'ACTIONS',
 | 
						|
              modalProps: {
 | 
						|
                actions: items,
 | 
						|
                onClick: handleItemClick,
 | 
						|
              },
 | 
						|
            }),
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          dispatch(
 | 
						|
            openDropdownMenu({
 | 
						|
              id: currentId,
 | 
						|
              keyboard: type !== 'click',
 | 
						|
              scrollKey,
 | 
						|
            }),
 | 
						|
          );
 | 
						|
        }
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [
 | 
						|
      dispatch,
 | 
						|
      currentId,
 | 
						|
      prefetchAccountId,
 | 
						|
      scrollKey,
 | 
						|
      onOpen,
 | 
						|
      handleItemClick,
 | 
						|
      open,
 | 
						|
      items,
 | 
						|
      forceDropdown,
 | 
						|
      handleClose,
 | 
						|
    ],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleMouseDown = useCallback(() => {
 | 
						|
    if (!open && document.activeElement instanceof HTMLElement) {
 | 
						|
      activeElement.current = document.activeElement;
 | 
						|
    }
 | 
						|
  }, [open]);
 | 
						|
 | 
						|
  const handleButtonKeyDown = useCallback(
 | 
						|
    (e: React.KeyboardEvent) => {
 | 
						|
      switch (e.key) {
 | 
						|
        case ' ':
 | 
						|
        case 'Enter':
 | 
						|
          handleMouseDown();
 | 
						|
          break;
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [handleMouseDown],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleKeyPress = useCallback(
 | 
						|
    (e: React.KeyboardEvent) => {
 | 
						|
      switch (e.key) {
 | 
						|
        case ' ':
 | 
						|
        case 'Enter':
 | 
						|
          handleClick(e);
 | 
						|
          e.stopPropagation();
 | 
						|
          e.preventDefault();
 | 
						|
          break;
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [handleClick],
 | 
						|
  );
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    return () => {
 | 
						|
      if (currentId === openDropdownId) {
 | 
						|
        handleClose();
 | 
						|
      }
 | 
						|
    };
 | 
						|
  }, [currentId, openDropdownId, handleClose]);
 | 
						|
 | 
						|
  let button: React.ReactElement;
 | 
						|
 | 
						|
  if (children) {
 | 
						|
    button = cloneElement(Children.only(children), {
 | 
						|
      onClick: handleClick,
 | 
						|
      onMouseDown: handleMouseDown,
 | 
						|
      onKeyDown: handleButtonKeyDown,
 | 
						|
      onKeyPress: handleKeyPress,
 | 
						|
      ref: targetRef,
 | 
						|
    });
 | 
						|
  } else if (icon && iconComponent) {
 | 
						|
    button = (
 | 
						|
      <IconButton
 | 
						|
        icon={!open ? icon : 'close'}
 | 
						|
        iconComponent={iconComponent}
 | 
						|
        title={title}
 | 
						|
        active={open}
 | 
						|
        disabled={disabled}
 | 
						|
        onClick={handleClick}
 | 
						|
        onMouseDown={handleMouseDown}
 | 
						|
        onKeyDown={handleButtonKeyDown}
 | 
						|
        onKeyPress={handleKeyPress}
 | 
						|
        ref={targetRef}
 | 
						|
      />
 | 
						|
    );
 | 
						|
  } else {
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  return (
 | 
						|
    <>
 | 
						|
      {button}
 | 
						|
 | 
						|
      <Overlay
 | 
						|
        show={open}
 | 
						|
        offset={offset}
 | 
						|
        placement='bottom'
 | 
						|
        flip
 | 
						|
        target={targetRef}
 | 
						|
        popperConfig={popperConfig}
 | 
						|
      >
 | 
						|
        {({ props, arrowProps, placement }) => (
 | 
						|
          <div {...props}>
 | 
						|
            <div className={`dropdown-animation dropdown-menu ${placement}`}>
 | 
						|
              <div
 | 
						|
                className={`dropdown-menu__arrow ${placement}`}
 | 
						|
                {...arrowProps}
 | 
						|
              />
 | 
						|
 | 
						|
              <DropdownMenu
 | 
						|
                items={items}
 | 
						|
                loading={loading}
 | 
						|
                scrollable={scrollable}
 | 
						|
                onClose={handleClose}
 | 
						|
                openedViaKeyboard={openedViaKeyboard}
 | 
						|
                renderItem={renderItem}
 | 
						|
                renderHeader={renderHeader}
 | 
						|
                onItemClick={onItemClick}
 | 
						|
              />
 | 
						|
            </div>
 | 
						|
          </div>
 | 
						|
        )}
 | 
						|
      </Overlay>
 | 
						|
    </>
 | 
						|
  );
 | 
						|
};
 |