[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.
This commit is contained in:
Yusuf Musleh
2024-03-21 14:56:22 +03:00
committed by GitHub
parent 9a6e12bd3b
commit 1dde30a0a2
7 changed files with 457 additions and 136 deletions

View File

@@ -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<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;

View File

@@ -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"
>
<ContentTagsDropDownSelector
key={`selector-${taxonomyId}`}
@@ -70,6 +73,8 @@ const CustomMenu = (props) => {
<div className="d-flex flex-row justify-content-end">
<div className="d-inline">
<Button
tabIndex="0"
ref={selectCancelRef}
variant="tertiary"
className="cancel-add-tags-button"
onClick={handleCancelStagedTags}
@@ -77,6 +82,8 @@ const CustomMenu = (props) => {
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
</Button>
<Button
tabIndex="0"
ref={selectAddRef}
variant="tertiary"
className="text-info-500 add-tags-button"
disabled={!(value && value.length)}
@@ -91,6 +98,13 @@ const CustomMenu = (props) => {
);
};
const disableActionKeys = (e) => {
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Backspace'];
if (arrowKeys.includes(e.code)) {
e.preventDefault();
}
};
const CustomLoadingIndicator = () => {
const intl = useIntl();
return (
@@ -110,6 +124,7 @@ const CustomIndicatorsContainer = (props) => {
const {
value,
handleCommitStagedTags,
selectInlineAddRef,
} = props.selectProps;
const intl = useIntl();
return (
@@ -119,9 +134,12 @@ const CustomIndicatorsContainer = (props) => {
<Button
variant="dark"
size="sm"
className="mt-2 mb-2 rounded-0"
className="mt-2 mb-2 rounded-0 inline-add-button"
onClick={handleCommitStagedTags}
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
ref={selectInlineAddRef}
tabIndex="0"
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
>
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
</Button>
@@ -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 (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
@@ -306,6 +372,18 @@ const ContentTagsCollapsible = ({
{canTagObject && (
<Select
onBlur={handleOnBlur}
styles={{
// Overriding 'x' button styles for staged tags when navigating by keyboard
multiValueRemove: (base, state) => ({
...base,
background: state.isFocused ? 'black' : base.background,
color: state.isFocused ? 'white' : base.color,
}),
}}
menuIsOpen={selectMenuIsOpen}
onFocus={onSelectMenuFocus}
onKeyDown={handleSelectOnKeyDown}
ref={/** @type {React.RefObject} */(selectRef)}
isMulti
isLoading={updateTags.isLoading}
@@ -332,6 +410,9 @@ const ContentTagsCollapsible = ({
handleCommitStagedTags={handleCommitStagedTags}
handleCancelStagedTags={handleCancelStagedTags}
searchTerm={searchTerm}
selectCancelRef={selectCancelRef}
selectAddRef={selectAddRef}
selectInlineAddRef={selectInlineAddRef}
value={stagedContentTags}
/>
)}

View File

@@ -9,22 +9,89 @@ import userEvent from '@testing-library/user-event';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
const taxonomyMockData = {
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 2,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
};
const nestedTaxonomyMockData = {
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1.1',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 1',
id: 12354,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 1.2',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 1',
id: 12355,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
};
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: jest.fn(),
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
// To mock nested call of useTaxonomyData in subtags dropdown
if (parentTagValue === 'Tag 1') {
return nestedTaxonomyMockData;
}
return taxonomyMockData;
}),
}));
const data = {
@@ -107,48 +174,6 @@ describe('<ContentTagsCollapsible />', () => {
);
}
function setupTaxonomyMock() {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
});
}
it('should render taxonomy tags data along content tags number badge', async () => {
const { container, getByText } = await getComponent();
expect(getByText('Taxonomy 1')).toBeInTheDocument();
@@ -157,7 +182,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `addStagedContentTag` when tag checked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -189,7 +213,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `removeStagedContentTag` when tag staged tag unchecked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -220,7 +243,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `setStagedTags` to clear staged tags when clicking inline "Add" button', async () => {
setupTaxonomyMock();
// Setup component to have staged tags
const { container, getByText } = await getComponent({
...data,
@@ -246,7 +268,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `setStagedTags` to clear staged tags when clicking "Add tags" button in dropdown', async () => {
setupTaxonomyMock();
// Setup component to have staged tags
const { container, getByText } = await getComponent({
...data,
@@ -348,7 +369,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should close dropdown selector when clicking away', async () => {
setupTaxonomyMock();
const { container, getByText, queryByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -376,8 +396,140 @@ describe('<ContentTagsCollapsible />', () => {
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should test keyboard navigation of add tags widget', async () => {
const {
container,
getByText,
queryByText,
queryAllByText,
} = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add a tag" button to open dropdown
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
fireEvent.mouseDown(addTagsButton);
// Wait for the dropdown selector for tags to open, Tag 3 should appear
// since it is not applied
expect(queryByText('Tag 3')).toBeInTheDocument();
/*
The dropdown data looks like the following:
│Tag 1
│ │
│ ├─ Tag 1.1
│ │
│ │
│ └─ Tag 1.2
│Tag 2
│Tag 3
*/
// Press tab to focus on first element in dropdown, Tag 1 should be focused
userEvent.tab();
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1Div).toHaveFocus();
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{arrowright}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
userEvent.keyboard('{arrowleft}');
expect(queryAllByText('Tag 1.1').length).toBe(1);
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{enter}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press down arrow to navigate to Tag 1.1, it should be focused
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press down arrow again to navigate to Tag 1.2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press down arrow again to navigate to Tag 2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.2, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press up arrow again to navigate to Tag 1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1Div).toHaveFocus();
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
userEvent.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press space key to check Tag 1.2, it should be staged
userEvent.keyboard('{space}');
const taxonomyId = 123;
const addedStagedTag = {
value: 'Tag%201,Tag%201.2',
label: 'Tag 1.2',
};
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
userEvent.keyboard('{enter}');
const tagValue = 'Tag%201,Tag%201.2';
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
// Press left arrow to navigate back to Tag 1, it should be focused
userEvent.keyboard('{arrowleft}');
expect(dropdownTag1Div).toHaveFocus();
// Press tab key it should jump to cancel button, it should be focused
userEvent.tab();
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
expect(dropdownCancel).toHaveFocus();
// Press tab again, it should exit and close the select menu, since there are not staged tags
userEvent.tab();
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press shift tab, focus back on select menu input, it should open the menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).toBeInTheDocument();
// Press shift tab again, it should focus out and close the select menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press tab again, the select menu should open, then press escape, it should close
userEvent.tab();
expect(queryByText('Tag 3')).toBeInTheDocument();
userEvent.keyboard('{escape}');
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should remove applied tags when clicking on `x` of tag bubble', async () => {
setupTaxonomyMock();
const { container, getByText } = await getComponent();
// Expand the Taxonomy to view applied tags

View File

@@ -114,10 +114,120 @@ const ContentTagsDropDownSelector = ({
return !!appliedTraversal[tag.value];
};
const isStagedExplicit = (tag) => {
// Traverse the staged tags tree using the lineage
let stagedTraversal = stagedContentTagsTree;
lineage.forEach(t => {
stagedTraversal = stagedTraversal[t]?.children || {};
});
return stagedTraversal[tag.value] && stagedTraversal[tag.value].explicit;
};
// Returns the state of the tag as a string: [Unchecked/Implicit/Checked]
const getTagState = (tag) => {
if (isApplied(tag) || isStagedExplicit(tag)) {
return intl.formatMessage(messages.taxonomyTagChecked);
}
if (isImplicit(tag)) {
return intl.formatMessage(messages.taxonomyTagImplicit);
}
return intl.formatMessage(messages.taxonomyTagUnchecked);
};
const isTopOfTagTreeDropdown = (index) => index === 0 && level === 0;
const loadMoreTags = useCallback(() => {
setNumPages((x) => x + 1);
}, []);
const handleKeyBoardNav = (e, hasChildren) => {
const keyPressed = e.code;
const currentElement = e.target;
const encapsulator = currentElement.closest('.dropdown-selector-tag-encapsulator');
// Get tag value with full lineage, this is URI encoded
const tagValueWithLineage = currentElement.querySelector('.pgn__form-checkbox-input')?.value;
// Extract and decode the actual tag value
let tagValue = tagValueWithLineage.split(',').slice(-1)[0];
tagValue = tagValue ? decodeURIComponent(tagValue) : tagValue;
if (keyPressed === 'ArrowRight') {
e.preventDefault();
if (tagValue && !isOpen(tagValue)) {
clickAndEnterHandler(tagValue);
}
} else if (keyPressed === 'ArrowLeft') {
e.preventDefault();
if (tagValue && isOpen(tagValue)) {
clickAndEnterHandler(tagValue);
} else {
// Handles case of jumping out of subtags to previous parent tag
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
prevParentTag?.focus();
}
} else if (keyPressed === 'ArrowUp') {
const prevSubTags = encapsulator?.previousElementSibling?.querySelectorAll('.dropdown-selector-tag-actions');
const prevSubTag = prevSubTags && prevSubTags[prevSubTags.length - 1];
const prevTag = encapsulator?.previousElementSibling?.querySelector('.dropdown-selector-tag-actions');
if (prevSubTag) {
// Handles case of jumping in to subtags
prevSubTag.focus();
} else if (prevTag) {
// Handles case of navigating to previous tag on same level
prevTag.focus();
} else {
// Handles case of jumping out of subtags to previous parent tag
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
prevParentTag?.focus();
}
} else if (keyPressed === 'ArrowDown') {
const subTagEncapsulator = encapsulator?.querySelector('.dropdown-selector-tag-encapsulator');
const nextSubTag = subTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
const nextTag = encapsulator?.nextElementSibling?.querySelector('.dropdown-selector-tag-actions');
if (nextSubTag) {
// Handles case of jumping into subtags
nextSubTag.focus();
} else if (nextTag) {
// Handles case of navigating to next tag on same level
nextTag?.focus();
} else {
// Handles case of jumping out of subtags to next focusable parent tag
let nextParentTagEncapsulator = encapsulator?.parentNode?.closest('.dropdown-selector-tag-encapsulator');
while (nextParentTagEncapsulator) {
const nextParentTag = nextParentTagEncapsulator.nextElementSibling?.querySelector(
'.dropdown-selector-tag-actions',
);
if (nextParentTag) {
nextParentTag.focus();
break;
}
nextParentTagEncapsulator = nextParentTagEncapsulator.parentNode.closest(
'.dropdown-selector-tag-encapsulator',
);
}
}
} else if (keyPressed === 'Enter') {
e.preventDefault();
if (hasChildren && tagValue) {
clickAndEnterHandler(tagValue);
} else {
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
checkbox.click();
}
} else if (keyPressed === 'Space') {
e.preventDefault();
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
checkbox.click();
}
};
return (
<div style={{ marginLeft: `${level * 1 }rem` }}>
{tagPages.isLoading ? (
@@ -131,24 +241,39 @@ const ContentTagsDropDownSelector = ({
) : null }
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPages.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
{tagPages.data?.map((tagData, i) => (
<div key={tagData.value} className="mt-1 ml-1 dropdown-selector-tag-encapsulator">
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
>
<div className="d-flex">
{/* The tabIndex and onKeyDown are needed to implement custom keyboard navigation */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="d-flex dropdown-selector-tag-actions"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={isTopOfTagTreeDropdown(i) ? 0 : -1} // Only enable tab into top of dropdown tree to set focus
onKeyDown={(e) => handleKeyBoardNav(e, tagData.childCount > 0)}
aria-label={
intl.formatMessage(
(isTopOfTagTreeDropdown(i)
? messages.taxonomyTagActionInstructionsAriaLabel
: messages.taxonomyTagActionsAriaLabel),
{ tag: tagData.value, tagState: getTagState(tagData) },
)
}
>
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
data-selectable-box="taxonomy-tags"
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
isIndeterminate={isApplied(tagData) || isImplicit(tagData)}
disabled={isApplied(tagData) || isImplicit(tagData)}
tabIndex="-1"
>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
@@ -158,8 +283,7 @@ const ContentTagsDropDownSelector = ({
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
tabIndex="-1"
/>
</div>
)}
@@ -178,17 +302,18 @@ const ContentTagsDropDownSelector = ({
/>
)}
</React.Fragment>
</div>
))}
{ hasMorePages
? (
<div>
<Button
tabIndex="0"
variant="tertiary"
iconBefore={Add}
onClick={loadMoreTags}
className="mb-2 taxonomy-tags-load-more-button px-0 text-info-500"
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>

View File

@@ -32,3 +32,8 @@
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
outline: none !important;
}
.dropdown-selector-tag-actions:focus-visible {
outline: solid 2px $info-900;
border-radius: 4px;
}

View File

@@ -199,68 +199,6 @@ describe('<ContentTagsDropDownSelector />', () => {
});
});
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
},
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
it('should render taxonomy tags drop down selector and change search term', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,

View File

@@ -25,9 +25,25 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"',
},
taxonomyTagsCheckboxAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
defaultMessage: '{tag} checkbox',
taxonomyTagChecked: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
defaultMessage: 'Checked',
},
taxonomyTagUnchecked: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-unchecked',
defaultMessage: 'Unchecked',
},
taxonomyTagImplicit: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-implicit',
defaultMessage: 'Implicit',
},
taxonomyTagActionInstructionsAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-action-instructions.aria.label',
defaultMessage: '{tagState} Tag: {tag}. Use the arrow keys to move among the tags in this taxonomy. Press space to select a tag.',
},
taxonomyTagActionsAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-actions.aria.label',
defaultMessage: '{tagState} Tag: {tag}',
},
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',