[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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user