Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Activating the toggle menu button while the listbox popup is expanded doesn't collapse the list using TalkBack on Mobile Chrome #1492

Open
ghost opened this issue Mar 27, 2023 · 4 comments

Comments

@ghost
Copy link

ghost commented Mar 27, 2023

  • downshift version: 6.1.7
  • node version: 14.20.0
  • npm (or yarn) version: 6.14.17

Environment:
Browser(s): Mobile Chrome;
OS: Android 12;
Device(s): Redmi 10C;
Screen Reader(s): TalkBack;

What you did:
I'm currently working on a project that uses the useCombobox and useMultipleSelection hooks from this library to create a combobox/autocomplete dropdown menu.

What happened:
I've encountered a problem with the accessibility of the combobox/autocomplete dropdown menu when activating the menu toggle button while the listbox popup is expanded doesn't collapse the list using TalkBack on Mobile Chrome. Unfortunately, this issue also appears in the examples provided in the library documentation. To illustrate the issue, I've included a video below demonstrating how this issue manifests in an example from the documentation (the same behavior is present in the component on my side). In addition, I've also included a video of the correct behavior on iOS devices.

Android | Chrome | TalkBack:
https://user-images.githubusercontent.com/110240086/227996701-706cf002-099a-45f0-8709-de342cd23107.mov

iOS | Safari | VoiceOver:
https://user-images.githubusercontent.com/110240086/227997091-81a0b145-494e-4690-ae3e-4f168411dd5d.mov

Reproduction repository:

Unfortunately, I'm unable to provide a reproduction repo at this point.

Problem description:
In an autocomplete/combobox dropdown menu when using Talkback in mobile Chrome, pressing the toggle menu button while the listbox is expanding does not collapse the listbox. If the text field is empty, pressing the toggle menu button opens the listbox and moves the focus to the input field. However, after that, the user cannot close the list menu by pressing the toggle menu button again.

This problem is reproduced not only in the project component I am working on but also appears in the examples provided in the library's documentation. Specifically, this issue is only present on Android devices when using the TalkBack screen reader. In contrast, everything works correctly on iOS devices when using the VoiceOver screen reader.

As this is a critical accessibility issue, I would like to report it and get feedback from the developers. I assume this problem is related to the library hooks, as it is not only present in my project but also in the examples provided in the documentation. Perhaps there are options in the hooks that can help fix this problem on my end, so I'd appreciate it if you could tell me about them.

Suggested solution:
The toggle menu button should expand and collapse the list popup using TalkBack on Mobile Chrome.

@sayinmehmet47
Copy link

sayinmehmet47 commented Aug 16, 2023

i faced the same issue in web also while using multiple selection can not use toggle button to close the list after selection.Seems when close the dropdown, the list stay as a focused and this line prevent it to be close

     <input
                ref={ref}
                placeholder={!hasSelectedItems ? placeholder : undefined}
                className={twMerge(
                  cn('w-full bg-gray-50 font-normal outline-none', {
                    'bg-red-50': errorText,
                  })
                )}
           {...getInputProps(
                  getDropdownProps({ preventKeyAction: isOpen })
                )}
              />

solution i found ;

              {...omit(
                  getInputProps(getDropdownProps({ preventKeyAction: isOpen })),
                  ['onFocus', 'onBlur']
                )}
Screen.Recording.2023-08-16.at.20.19.17.mov

@silviuaavram
Copy link
Collaborator

silviuaavram commented Aug 19, 2023

@sayinmehmet47 what is happening there is:

  • your menu is opening with an option highlighted.
  • you are triggering an input blur by clicking the toggle button.
  • blur with a highlighted option will select that option.

Our multiple selection example works, so I'm curious why yours does not. If you perform the same testing on https://www.downshift-js.com/use-multiple-selection#usage-with-combobox, you will not get a selection when clicking the toggle button. Are you using getToggleButtonProps?

Also, your workaround does not seem to be quite right. You are preventing opening the combobox on click and selection on blur, which is what users expect from an a11y point of view. You need a proper fix for your scenario, but I cannot help you without a code usage or a sandbox.

@sayinmehmet47
Copy link

@silviuaavram thanks for answer. Actually my multiselect component works perfectly when i try it in storybook. I used this component with use hook form library, to get the inputs from the components. I want to share some part of my code that i did. I actually sticked to the documentation here

function MultiSelectAutocompleteInner<T>(
  {
    defaultValue = [],
    getFilteredItems,
    getOptionLabel,
    label,
    labelTestId,
    placeholder,
    emptyOptionsText,
    selectedItems: controlledSelectedItems,
    isLoading = false,
    onInputChange,
    onSelectionChange,
    selectionOptions,
    successText,
    successTextTestId,
    errorText,
    errorTextTestId,
    required = false,
    testId,
  }: MultiSelectAutocompleteProps<T>,
  ref: ForwardedRef<HTMLInputElement>
) {
  const scrollRef = useRef<HTMLDivElement>(null);

  const [inputValue, setInputValue] = useState<string>('');
  const [uncontrolledItems, setSelectedItems] = useState<T[]>(defaultValue);

  const selectedItems = controlledSelectedItems ?? uncontrolledItems;

  const hasSelectedItems = selectedItems?.length > 0;

  const filteredItems = getFilteredItems
    ? getFilteredItems(inputValue, selectedItems)
    : selectionOptions ?? [];

  const {
    getSelectedItemProps,
    getDropdownProps,
    removeSelectedItem,
    addSelectedItem,
  } = useMultipleSelection<T>({
    selectedItems,
    onStateChange({ selectedItems: newSelectedItems, type }) {
      switch (type) {
        case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
        case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
        case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
        case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
          if (onSelectionChange) {
            onSelectionChange(newSelectedItems ?? []);
          }
          setSelectedItems(newSelectedItems ?? []);
          setInputValue('');
          break;
        default:
          break;
      }
    },
  });

  const {
    isOpen,
    getToggleButtonProps,
    getLabelProps,
    getMenuProps,
    getInputProps,
    highlightedIndex,
    getItemProps,
    selectedItem,
    openMenu,
    toggleMenu,
  } = useCombobox({
    items: filteredItems,
    itemToString(item) {
      return item ? getOptionLabel(item) : '';
    },
    defaultHighlightedIndex: 0, // after selection, highlight the first item.
    selectedItem: null,
    stateReducer(state, actionAndChanges) {
      const { changes, type } = actionAndChanges;
      switch (type) {
        case useCombobox.stateChangeTypes.InputKeyDownEnter:
        case useCombobox.stateChangeTypes.ItemClick:
          return {
            ...changes,
            isOpen: true, // keep the menu open after selection.
            highlightedIndex: 0, // with the first option highlighted.
            inputValue: '', // also, clear the inputValue to
          };
        default:
          return changes;
      }
    },
    onStateChange({
      inputValue: newInputValue,
      type,
      selectedItem: newSelectedItem,
    }) {
      switch (type) {
        case useCombobox.stateChangeTypes.InputKeyDownEnter:
        case useCombobox.stateChangeTypes.ItemClick:
        case useCombobox.stateChangeTypes.InputBlur:
          if (newSelectedItem && Array.isArray(selectedItems)) {
            setSelectedItems([...selectedItems, newSelectedItem]);
            if (onSelectionChange) {
              onSelectionChange([...selectedItems, newSelectedItem]);
            }
          } else if (newSelectedItem) {
            setSelectedItems([newSelectedItem]);
            if (onSelectionChange) {
              onSelectionChange([newSelectedItem]);
            }
          }

          scrollRef.current?.scroll({
            top: scrollRef.current?.scrollHeight,
            behavior: 'smooth',
          });

          break;

        case useCombobox.stateChangeTypes.InputChange:
          setInputValue(newInputValue ?? '');
          if (onInputChange) {
            onInputChange(newInputValue ?? '');
          }
          break;
        default:
          break;
      }
    },
  });

  return (
    <div className="relative w-full">
      <div className="flex flex-col gap-1 font-light">
        {label && (
          <LegacyLabel
            {...getLabelProps()}
            value={label + (required ? ' *' : '')}
            testId={labelTestId}
          />
        )}
        {}
        <div className="relative w-full">
          <button
            aria-label="toggle menu"
            className={cn(
              'absolute right-0 top-0 px-2 py-3 transition-transform',
              {
                'rotate-180': isOpen,
              }
            )}
            type="button"
            {...getToggleButtonProps()}
            onClick={(e) => {
              e.stopPropagation();
              toggleMenu();
            }}
          >
            <ChevronDownIcon className="h-4 w-4" />
          </button>
          {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */}
          <div
            ref={scrollRef}
            className={twMerge(
              cn(
                'inline-flex max-h-36 w-full flex-wrap items-center gap-2 overflow-auto rounded-lg border border-gray-300 bg-gray-50 py-2 pl-3 pr-6 shadow-sm',
                'focus-within:border-blue-500 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-500 dark:border-blue-400 dark:bg-blue-100 dark:focus-within:border-blue-500 dark:focus-within:ring-blue-500',
                {
                  'border-red-500 bg-red-50': errorText,
                  'focus-within:border-red-500 focus-within:ring-red-500':
                    errorText,
                }
              )
            )}
            data-testid={testId}
            onClick={() => {
              openMenu();
            }}
          >
            {selectedItems?.map(function renderSelectedItem(
              selectedItemForRender,
              index
            ) {
              return (
                /* eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */
                <span
                  style={{ overflowAnchor: 'none' }}
                  className="flex rounded-md border border-gray-500 bg-white p-1 px-1 font-normal focus:bg-blue-100"
                  key={`selected-item-${index}`}
                  {...getSelectedItemProps({
                    selectedItem: selectedItemForRender,
                    index,
                  })}
                  onClick={(e) => {
                    e.stopPropagation();
                    addSelectedItem(selectedItemForRender);
                  }}
                >
                  {getOptionLabel(selectedItemForRender)}
                  <button
                    type="button"
                    className="cursor-pointer"
                    onClick={(e) => {
                      e.stopPropagation();
                      removeSelectedItem(selectedItemForRender);
                    }}
                  >
                    <XMarkIcon className="ml-1 h-4 w-4" />
                  </button>
                </span>
              );
            })}
            <div
              style={{ overflowAnchor: 'auto' }}
              className="flex grow gap-0.5"
            >
              <input
                ref={ref}
                placeholder={!hasSelectedItems ? placeholder : undefined}
                className={twMerge(
                  cn('w-full bg-gray-50 font-normal outline-none', {
                    'bg-red-50': errorText,
                  })
                )}

                {...getInputProps(
                  getDropdownProps({ preventKeyAction: isOpen })
                )}
              />
            </div>
          </div>
          <ul
            className={cn(
              'w-inherit absolute mt-1 max-h-80 w-full overflow-auto rounded-b-md border border-gray-200 bg-white p-0 text-gray-500 shadow',
              {
                hidden:
                  (!isOpen && !isLoading) ||
                  (isOpen &&
                    !isLoading &&
                    !filteredItems.length &&
                    !emptyOptionsText),
              }
            )}
            {...getMenuProps()}
          >
            {isLoading && (
              <li className="flex flex-col px-3 py-2">
                <Loading size="sm" />
              </li>
            )}
            {isOpen &&
              !isLoading &&
              filteredItems.map((item, index) => (
                <li
                  className={cn(
                    highlightedIndex === index && 'bg-gray-100',
                    selectedItem === item && 'font-bold',
                    'flex cursor-pointer flex-col px-3 py-2'
                  )}
                  key={index}
                  {...getItemProps({ item, index })}
                >
                  <span className="text-sm text-gray-700">
                    {getOptionLabel(item)}
                  </span>
                </li>
              ))}
            {isOpen &&
              emptyOptionsText &&
              !isLoading &&
              filteredItems.length === 0 && (
                <li className="flex flex-col px-3 py-2">{emptyOptionsText}</li>
              )}
          </ul>
        </div>

        {successText && (
          <LegacyHelperText.Success testId={successTextTestId}>
            {successText}
          </LegacyHelperText.Success>
        )}
        {errorText && (
          <LegacyHelperText.Error testId={errorTextTestId}>
            {errorText}
          </LegacyHelperText.Error>
        )}
      </div>
    </div>
  );
}

@silviuaavram
Copy link
Collaborator

          <button
            aria-label="toggle menu"
            className={cn(
              'absolute right-0 top-0 px-2 py-3 transition-transform',
              {
                'rotate-180': isOpen,
              }
            )}
            type="button"
            {...getToggleButtonProps()}
            onClick={(e) => {
              e.stopPropagation();
              toggleMenu();
            }}
          >

Why do you need that onClick for? getToggleButtonProps does that by default. Just remove it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants