From 1dde30a0a2d4a5208155fa145ab5db19edd82dd4 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Thu, 21 Mar 2024 14:56:22 +0300 Subject: [PATCH] [FC-0036] feat: Make tags widget keyboard accessible (#900) Adds the ability to navigate the new "Add Tags" widget using the keyboard, making it fully accessible through the keyboard. --- .../ContentTagsCollapsible.d.ts | 4 + .../ContentTagsCollapsible.jsx | 91 +++++- .../ContentTagsCollapsible.test.jsx | 268 ++++++++++++++---- .../ContentTagsDropDownSelector.jsx | 141 ++++++++- .../ContentTagsDropDownSelector.scss | 5 + .../ContentTagsDropDownSelector.test.jsx | 62 ---- src/content-tags-drawer/messages.js | 22 +- 7 files changed, 457 insertions(+), 136 deletions(-) diff --git a/src/content-tags-drawer/ContentTagsCollapsible.d.ts b/src/content-tags-drawer/ContentTagsCollapsible.d.ts index 55759439e..364e7d291 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.d.ts +++ b/src/content-tags-drawer/ContentTagsCollapsible.d.ts @@ -1,3 +1,4 @@ +import { Ref } from 'react'; import type {} from 'react-select/base'; // This import is necessary for module augmentation. // It allows us to extend the 'Props' interface in the 'react-select/base' module @@ -16,6 +17,9 @@ export interface TaxonomySelectProps { appliedContentTagsTree: Record; stagedContentTagsTree: Record; checkedTags: string[]; + selectCancelRef: Ref, + selectAddRef: Ref, + selectInlineAddRef: Ref, handleCommitStagedTags: () => void; handleCancelStagedTags: () => void; handleSelectableBoxChange: React.ChangeEventHandler; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx index 0923ec6e3..fced38cb8 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -41,6 +41,8 @@ const CustomMenu = (props) => { handleCommitStagedTags, handleCancelStagedTags, searchTerm, + selectCancelRef, + selectAddRef, value, } = props.selectProps; const intl = useIntl(); @@ -56,6 +58,7 @@ const CustomMenu = (props) => { className="taxonomy-tags-selectable-box-set" onChange={handleSelectableBoxChange} value={checkedTags} + tabIndex="-1" > {
@@ -219,8 +237,13 @@ const ContentTagsCollapsible = ({ }) => { const intl = useIntl(); const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData; + const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null)); + const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null)); + const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null)); const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null)); + const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false); + const { tagChangeHandler, removeAppliedTagHandler, @@ -250,9 +273,11 @@ const ContentTagsCollapsible = ({ const handleSearchChange = React.useCallback((value, { action }) => { if (action === 'input-blur') { - // Cancel/clear search if focused away from select input - handleSearch.cancel(); - setSearchTerm(''); + if (!selectMenuIsOpen) { + // Cancel/clear search if focused away from select input and menu closed + handleSearch.cancel(); + setSearchTerm(''); + } } else if (action === 'input-change') { if (value === '') { // No need to debounce when search term cleared. Clear debounce function @@ -262,7 +287,7 @@ const ContentTagsCollapsible = ({ handleSearch(value); } } - }, []); + }, [selectMenuIsOpen, setSearchTerm, handleSearch]); // onChange handler for react-select component, currently only called when // staged tags in the react-select input are removed or fully cleared. @@ -287,14 +312,55 @@ const ContentTagsCollapsible = ({ handleStagedTagsMenuChange([]); selectRef.current?.blur(); setSearchTerm(''); + setSelectMenuIsOpen(false); }, [commitStagedTags, handleStagedTagsMenuChange, selectRef, setSearchTerm]); const handleCancelStagedTags = React.useCallback(() => { handleStagedTagsMenuChange([]); selectRef.current?.blur(); setSearchTerm(''); + setSelectMenuIsOpen(false); }, [handleStagedTagsMenuChange, selectRef, setSearchTerm]); + const handleSelectOnKeyDown = (event) => { + const focusedElement = event.target; + + if (event.key === 'Escape') { + setSelectMenuIsOpen(false); + } else if (event.key === 'Tab') { + // Keep the menu open when navigating inside the select menu + setSelectMenuIsOpen(true); + + // Determine when to close the menu when navigating with keyboard + if (!event.shiftKey) { // Navigating forwards + if (focusedElement === selectAddRef.current) { + setSelectMenuIsOpen(false); + } else if (focusedElement === selectCancelRef.current && selectAddRef.current?.disabled) { + setSelectMenuIsOpen(false); + } + // Navigating backwards + // @ts-ignore inputRef actually exists under the current selectRef + } else if (event.shiftKey && focusedElement === selectRef.current?.inputRef) { + setSelectMenuIsOpen(false); + } + } + }; + + // Open the select menu and make sure the search term is cleared when focused + const onSelectMenuFocus = React.useCallback(() => { + setSelectMenuIsOpen(true); + setSearchTerm(''); + }, [setSelectMenuIsOpen, setSearchTerm]); + + // Handles logic to close the select menu when clicking outside + const handleOnBlur = React.useCallback((event) => { + // Check if a target we are focusing to is an element in our select menu, if not close it + const menuClasses = ['dropdown-selector', 'inline-add-button', 'cancel-add-tags-button']; + if (!event.relatedTarget || !menuClasses.some(cls => event.relatedTarget.className?.includes(cls))) { + setSelectMenuIsOpen(false); + } + }, [setSelectMenuIsOpen]); + return (
@@ -306,6 +372,18 @@ const ContentTagsCollapsible = ({ {canTagObject && (