fix: Improve Dropdown component accessibility (#35373)
				
					
				
			This commit is contained in:
		
				
					committed by
					
						
						David Roetzel
					
				
			
			
				
	
			
			
			
						parent
						
							ef6f5f9357
						
					
				
				
					commit
					a79dbf8334
				
			@@ -5,6 +5,7 @@ import {
 | 
			
		||||
  useCallback,
 | 
			
		||||
  cloneElement,
 | 
			
		||||
  Children,
 | 
			
		||||
  useId,
 | 
			
		||||
} from 'react';
 | 
			
		||||
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
@@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
 | 
			
		||||
import type {
 | 
			
		||||
  OffsetValue,
 | 
			
		||||
  UsePopperOptions,
 | 
			
		||||
  Placement,
 | 
			
		||||
} from 'react-overlays/esm/usePopper';
 | 
			
		||||
 | 
			
		||||
import { fetchRelationships } from 'mastodon/actions/accounts';
 | 
			
		||||
@@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  scrollable?: boolean;
 | 
			
		||||
  placement?: Placement;
 | 
			
		||||
  /**
 | 
			
		||||
   * Prevent the `ScrollableList` with this scrollKey
 | 
			
		||||
   * from being scrolled while the dropdown is open
 | 
			
		||||
   */
 | 
			
		||||
  scrollKey?: string;
 | 
			
		||||
  status?: ImmutableMap<string, unknown>;
 | 
			
		||||
  forceDropdown?: boolean;
 | 
			
		||||
@@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
  title = 'Menu',
 | 
			
		||||
  disabled,
 | 
			
		||||
  scrollable,
 | 
			
		||||
  placement = 'bottom',
 | 
			
		||||
  status,
 | 
			
		||||
  forceDropdown = false,
 | 
			
		||||
  renderItem,
 | 
			
		||||
@@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
  );
 | 
			
		||||
  const [currentId] = useState(id++);
 | 
			
		||||
  const open = currentId === openDropdownId;
 | 
			
		||||
  const activeElement = useRef<HTMLElement | null>(null);
 | 
			
		||||
  const targetRef = useRef<HTMLButtonElement | null>(null);
 | 
			
		||||
  const buttonRef = useRef<HTMLButtonElement | null>(null);
 | 
			
		||||
  const menuId = useId();
 | 
			
		||||
  const prefetchAccountId = status
 | 
			
		||||
    ? status.getIn(['account', 'id'])
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  const handleClose = useCallback(() => {
 | 
			
		||||
    if (activeElement.current) {
 | 
			
		||||
      activeElement.current.focus({ preventScroll: true });
 | 
			
		||||
      activeElement.current = null;
 | 
			
		||||
    if (buttonRef.current) {
 | 
			
		||||
      buttonRef.current.focus({ preventScroll: true });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dispatch(
 | 
			
		||||
@@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
    [handleClose, onItemClick, items],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleClick = useCallback(
 | 
			
		||||
  const toggleDropdown = useCallback(
 | 
			
		||||
    (e: React.MouseEvent | React.KeyboardEvent) => {
 | 
			
		||||
      const { type } = e;
 | 
			
		||||
 | 
			
		||||
@@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
@@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
 | 
			
		||||
  let button: React.ReactElement;
 | 
			
		||||
 | 
			
		||||
  const buttonProps = {
 | 
			
		||||
    disabled,
 | 
			
		||||
    onClick: toggleDropdown,
 | 
			
		||||
    'aria-expanded': open,
 | 
			
		||||
    'aria-controls': menuId,
 | 
			
		||||
    ref: buttonRef,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (children) {
 | 
			
		||||
    button = cloneElement(Children.only(children), {
 | 
			
		||||
      onClick: handleClick,
 | 
			
		||||
      onMouseDown: handleMouseDown,
 | 
			
		||||
      onKeyDown: handleButtonKeyDown,
 | 
			
		||||
      onKeyPress: handleKeyPress,
 | 
			
		||||
      ref: targetRef,
 | 
			
		||||
    });
 | 
			
		||||
    button = cloneElement(Children.only(children), buttonProps);
 | 
			
		||||
  } else if (icon && iconComponent) {
 | 
			
		||||
    button = (
 | 
			
		||||
      <IconButton
 | 
			
		||||
@@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
        iconComponent={iconComponent}
 | 
			
		||||
        title={title}
 | 
			
		||||
        active={open}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
        onClick={handleClick}
 | 
			
		||||
        onMouseDown={handleMouseDown}
 | 
			
		||||
        onKeyDown={handleButtonKeyDown}
 | 
			
		||||
        onKeyPress={handleKeyPress}
 | 
			
		||||
        ref={targetRef}
 | 
			
		||||
        {...buttonProps}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
@@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
 | 
			
		||||
      <Overlay
 | 
			
		||||
        show={open}
 | 
			
		||||
        offset={offset}
 | 
			
		||||
        placement='bottom'
 | 
			
		||||
        placement={placement}
 | 
			
		||||
        flip
 | 
			
		||||
        target={targetRef}
 | 
			
		||||
        target={buttonRef}
 | 
			
		||||
        popperConfig={popperConfig}
 | 
			
		||||
      >
 | 
			
		||||
        {({ props, arrowProps, placement }) => (
 | 
			
		||||
          <div {...props}>
 | 
			
		||||
          <div {...props} id={menuId}>
 | 
			
		||||
            <div className={`dropdown-animation dropdown-menu ${placement}`}>
 | 
			
		||||
              <div
 | 
			
		||||
                className={`dropdown-menu__arrow ${placement}`}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@ interface Props {
 | 
			
		||||
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
 | 
			
		||||
  active?: boolean;
 | 
			
		||||
  expanded?: boolean;
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
@@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
 | 
			
		||||
      activeStyle,
 | 
			
		||||
      onClick,
 | 
			
		||||
      onKeyDown,
 | 
			
		||||
      onKeyPress,
 | 
			
		||||
      onMouseDown,
 | 
			
		||||
      active = false,
 | 
			
		||||
      disabled = false,
 | 
			
		||||
@@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
 | 
			
		||||
      [disabled, onClick],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
 | 
			
		||||
      useCallback(
 | 
			
		||||
        (e) => {
 | 
			
		||||
          if (!disabled) {
 | 
			
		||||
            onKeyPress?.(e);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        [disabled, onKeyPress],
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
 | 
			
		||||
      useCallback(
 | 
			
		||||
        (e) => {
 | 
			
		||||
@@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
 | 
			
		||||
        onClick={handleClick}
 | 
			
		||||
        onMouseDown={handleMouseDown}
 | 
			
		||||
        onKeyDown={handleKeyDown}
 | 
			
		||||
        onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
 | 
			
		||||
        style={buttonStyle}
 | 
			
		||||
        tabIndex={tabIndex}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
 
 | 
			
		||||
@@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => {
 | 
			
		||||
 | 
			
		||||
  const menu = useMemo(() => {
 | 
			
		||||
    const arr: MenuItem[] = [
 | 
			
		||||
      { text: intl.formatMessage(messages.filters), href: '/filters' },
 | 
			
		||||
      { text: intl.formatMessage(messages.mutes), to: '/mutes' },
 | 
			
		||||
      { text: intl.formatMessage(messages.blocks), to: '/blocks' },
 | 
			
		||||
      {
 | 
			
		||||
        text: intl.formatMessage(messages.domainBlocks),
 | 
			
		||||
        to: '/domain_blocks',
 | 
			
		||||
        href: '/filters',
 | 
			
		||||
        text: intl.formatMessage(messages.filters),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        to: '/mutes',
 | 
			
		||||
        text: intl.formatMessage(messages.mutes),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        to: '/blocks',
 | 
			
		||||
        text: intl.formatMessage(messages.blocks),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        to: '/domain_blocks',
 | 
			
		||||
        text: intl.formatMessage(messages.domainBlocks),
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    arr.push(
 | 
			
		||||
      null,
 | 
			
		||||
      {
 | 
			
		||||
        href: '/settings/privacy',
 | 
			
		||||
@@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => {
 | 
			
		||||
        href: '/settings/export',
 | 
			
		||||
        text: intl.formatMessage(messages.importExport),
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (canManageReports(permissions)) {
 | 
			
		||||
      arr.push(null, {
 | 
			
		||||
@@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => {
 | 
			
		||||
  }, [intl, dispatch, permissions]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dropdown items={menu}>
 | 
			
		||||
    <Dropdown items={menu} placement='bottom-start'>
 | 
			
		||||
      <button className='column-link column-link--transparent'>
 | 
			
		||||
        <Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3874,16 +3874,18 @@ a.account__display-name {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 12px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  padding: 12px;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  border: 0;
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  color: $secondary-text-color;
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  border: 0;
 | 
			
		||||
  border-left: 4px solid transparent;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
  &:hover,
 | 
			
		||||
  &:focus,
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user