src/applications/disability-benefits/all-claims/components/ComboBox.jsx
// Description: ComboBox component for the disability benefits form.
import React from 'react';
import PropTypes from 'prop-types';
import { VaTextInput } from '@department-of-veterans-affairs/component-library/dist/react-bindings';
import { fullStringSimilaritySearch } from 'platform/forms-system/src/js/utilities/addDisabilitiesStringSearch';
const COMBOBOX_LIST_MAX_HEIGHT = '440px';
const defaultHighlightedIndex = -1;
const getSelectionMessage = (searchTerm = '') => {
return searchTerm.trim() === '' ? '' : `${searchTerm} is selected.`;
};
const calculateResults = (searchTerm, value, numResults, selectionMade) => {
let results = numResults;
// Free text drawn
if (!selectionMade && searchTerm.length && searchTerm !== value) {
results += 1;
}
return results;
};
const formatResultsMessage = results => {
if (results === 1) {
return '1 result available.';
}
return `${results} results available.`;
};
// helper function for results string. No `this.` so not in class.
const getScreenReaderResults = (
searchTerm,
value,
numResults,
selectionMade,
) => {
if (searchTerm.trim() === '') {
return 'Input is empty. Please enter a condition.';
}
if (selectionMade) {
return getSelectionMessage(searchTerm);
}
const results = calculateResults(
searchTerm,
value,
numResults,
selectionMade,
);
return formatResultsMessage(results);
};
// This is a combobox component that is used in the revisedAddDisabilities page.
// Originally, the addDisabilities page used the AutosuggestField component from the platform-forms-system package.
// A new component was created to make suggestions to the veteran more understandable when selecting a new condition to claim.
// Search functions for use with this component are located in:
// src/platform/forms-system/src/js/utilities/addDisabilitiesStringSearch.js
export class ComboBox extends React.Component {
constructor(props) {
super(props);
// is there a cleaner way to pass this in?
this.disabilitiesArr = props.uiSchema['ui:options'].listItems;
this.state = {
bump: false,
// Autopopulate input with existing form data:
searchTerm: props.formData,
value: props.formData,
highlightedIndex: defaultHighlightedIndex,
ariaLive1: '',
ariaLive2: '',
filteredOptions: [],
selectionMade: true,
};
this.inputRef = React.createRef();
this.listRef = React.createRef();
}
componentDidMount() {
document.addEventListener('click', this.handleClickOutsideList, true);
}
// Triggers updates to the list of items on state change
componentDidUpdate(prevProps, prevState) {
this.updateFilterOptions(prevState);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClickOutsideList, true);
}
// Handler for closing the list when a user clicks outside of it
handleClickOutsideList = e => {
if (this.listRef.current && !this.listRef.current.contains(e.target)) {
const { searchTerm } = this.state;
this.setSelectedState(searchTerm);
this.sendFocusToInput(this.inputRef);
}
};
// handler for main form input
handleSearchChange = e => {
const { bump } = this.state;
const newTextValue = e.target.value;
// this.filterOptions();
this.setState({
searchTerm: newTextValue,
bump: !bump,
selectionMade: false,
});
this.props.onChange(newTextValue);
// send focus back to input after selection in case user wants to append something else
this.sendFocusToInput(this.inputRef);
};
// Handler for the blue background highlight class.
handleMouseEnter(e, optionIndex) {
this.setState({ highlightedIndex: optionIndex });
}
// Keyboard handling for combobox list options
handleKeyPress = e => {
const { highlightedIndex, searchTerm } = this.state;
const list = this.listRef.current;
let index = highlightedIndex;
switch (e.key) {
// On Tab, user input should remain as-is, list should close, focus goes to save button.
case 'Tab':
if (list.children.length) {
this.setSelectedState(searchTerm);
}
break;
// Up and Down arrow keys should navigate to the respective next item in the list.
case 'ArrowUp':
// if user is in first item of the list and arrows up, should return to input field
if (index === 0) {
this.sendFocusToInput(this.inputRef);
this.setState({ highlightedIndex: -1 });
} else {
index = this.decrementIndex(index);
this.scrollIntoView(index);
this.setState({ highlightedIndex: index });
this.optionFocus(index);
}
e.preventDefault();
break;
case 'ArrowDown':
index = this.incrementIndex(index);
this.scrollIntoView(index);
this.setState({ highlightedIndex: index });
this.optionFocus(index);
e.preventDefault();
break;
// On Enter, select the highlighted option and close the list. Focus on text input.
case 'Enter':
e.preventDefault();
if (index === -1) {
this.selectOption(searchTerm);
} else {
this.selectOptionWithKeyboard(e, index, list, searchTerm);
}
break;
// On Escape, user input should remain as-is, list should collapse. Focus on text input.
case 'Escape':
this.setSelectedState(searchTerm);
this.sendFocusToInput(this.inputRef);
e.preventDefault();
break;
// All other cases treat as regular user input into the text field.
default:
// focus goes to input box by default
this.sendFocusToInput(this.inputRef);
// highlight dynamic free text option
this.setState({
highlightedIndex: defaultHighlightedIndex,
});
break;
}
};
// Filters list of conditions based on free-text input
filterOptions = () => {
const { searchTerm, value, bump, selectionMade } = this.state;
const options = this.disabilitiesArr;
let filtered = fullStringSimilaritySearch(searchTerm, options);
if (searchTerm.length === 0) {
filtered = [];
}
if (selectionMade && searchTerm === value) {
filtered = [];
}
let ariaLive1;
let ariaLive2;
if (bump) {
ariaLive1 = getScreenReaderResults(
searchTerm,
value,
filtered.length,
selectionMade,
);
ariaLive2 = '';
} else {
ariaLive1 = '';
ariaLive2 = getScreenReaderResults(
searchTerm,
value,
filtered.length,
selectionMade,
);
}
this.setState({
filteredOptions: filtered,
ariaLive1,
ariaLive2,
});
};
// Scroll helper for keyboard arrow interactions with list items
scrollIntoView = index => {
const list = this.listRef.current;
const currentItem = list.children[index];
if (currentItem) {
const { scrollTop, clientHeight } = list;
const { offsetTop, clientHeight: itemHeight } = currentItem;
const isItemFullyVisible =
offsetTop >= scrollTop &&
offsetTop + itemHeight <= scrollTop + clientHeight;
if (!isItemFullyVisible) {
currentItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}
};
setSelectedState(option) {
const itemSelectedMessage = getSelectionMessage(option);
this.setState({
value: option,
searchTerm: option,
filteredOptions: [],
highlightedIndex: defaultHighlightedIndex,
selectionMade: true,
ariaLive1: itemSelectedMessage,
ariaLive2: '',
});
}
sendFocusToInput = ref => {
const { shadowRoot } = ref.current;
const input = shadowRoot.querySelector('input');
input.focus();
};
// Helpers for arrow key navigation
decrementIndex = index => {
if (index > 0) {
return index - 1;
}
return index;
};
incrementIndex = index => {
const { filteredOptions } = this.state;
const maxIndex = filteredOptions.length;
if (index < maxIndex) {
return index + 1;
}
return index;
};
optionFocus(index) {
const focusOption = document.getElementById(`option-${index}`);
focusOption?.focus();
}
// Click handler for a list item
selectOption(option) {
this.setSelectedState(option);
const { onChange } = this.props;
onChange(option);
// Send focus to input element for additional user input.
this.sendFocusToInput(this.inputRef);
}
// Keyboard handler for a list item. Need to check index against list for selection via keyboard
selectOptionWithKeyboard(e, index, list, searchTerm) {
if (index > 0) {
this.selectOption(list.children[index].textContent);
} else if (index === 0) {
this.selectOption(searchTerm);
}
}
// Handler for updating the visible list of items when state changes
updateFilterOptions(prevState) {
if (prevState.searchTerm !== this.state.searchTerm) {
this.filterOptions();
}
if (prevState.value !== '' && this.state.value === '') {
this.setState({ searchTerm: '' });
}
}
// Creates the dynamic element for free text user entry.
drawFreeTextOption(option) {
const { highlightedIndex, value, selectionMade } = this.state;
if ((selectionMade && option === value) || option?.length < 1) {
return null;
}
let classNameStr = 'cc-combobox__option cc-combobox__option--free';
if (highlightedIndex === 0) {
classNameStr += ' cc-combobox__option--active';
}
return (
<li
key={0}
className={classNameStr}
onClick={() => {
this.selectOption(option);
}}
id="option-0"
style={{ cursor: 'pointer' }}
tabIndex={0}
onMouseEnter={e => {
this.handleMouseEnter(e, 0);
}}
onKeyDown={this.handleKeyPress}
label="new-condition-option"
role="option"
aria-selected={this.state.highlightedIndex === 0 ? 'true' : 'false'}
>
Enter your condition as "
<span style={{ fontWeight: 'bold' }}>{option}</span>"
</li>
);
}
render() {
const { searchTerm, ariaLive1, ariaLive2, filteredOptions } = this.state;
const autocompleteHelperText =
searchTerm?.length > 0
? null
: `
When autocomplete results are available use up and down arrows to
review and enter to select. Touch device users, explore by touch or
with swipe gestures.
`;
return (
<div className="cc-combobox">
<VaTextInput
label={this.props.uiSchema['ui:title']}
required
name="combobox-input"
id={this.props.idSchema.$id}
value={this.state.value}
onInput={this.handleSearchChange}
onChange={this.handleSearchChange}
onKeyDown={this.handleKeyPress}
ref={this.inputRef}
message-aria-describedby={autocompleteHelperText}
data-testid="combobox-input"
/>
<ul
className={
filteredOptions.length > 0
? 'cc-combobox__list cc-combobox__list--open'
: 'cc-combobox__list'
}
style={{ maxHeight: COMBOBOX_LIST_MAX_HEIGHT }}
role="listbox"
ref={this.listRef}
aria-hidden={filteredOptions.length === 0}
aria-label="List of matching conditions"
id="combobox-list"
aria-activedescendant={
filteredOptions.length > 0
? `option-${this.state.highlightedIndex}`
: null
}
tabIndex={-1}
>
{this.drawFreeTextOption(searchTerm)}
{filteredOptions &&
filteredOptions.map((option, index) => {
const optionIndex = index + 1;
let classNameStr = 'cc-combobox__option';
if (optionIndex === this.state.highlightedIndex) {
classNameStr += ' cc-combobox__option--active';
}
return (
<li
key={optionIndex}
className={classNameStr}
onClick={() => {
this.selectOption(option);
}}
style={{ cursor: 'pointer' }}
tabIndex={0}
onMouseEnter={e => {
this.handleMouseEnter(e, optionIndex);
}}
onKeyDown={this.handleKeyPress}
label={option}
role="option"
aria-selected={
optionIndex === this.state.highlightedIndex
? 'true'
: 'false'
}
id={`option-${optionIndex}`}
>
{option}
</li>
);
})}
</ul>
<div
className="cc-combobox__status vads-u-visibility--screen-reader"
role="alert"
aria-live="polite"
aria-atomic="true"
id={`${this.props.idSchema.$id}-live-sr-results1`}
>
{ariaLive1}
</div>
<div
className="cc-combobox__status vads-u-visibility--screen-reader"
role="alert"
aria-live="polite"
aria-atomic="true"
id={`${this.props.idSchema.$id}-live-sr-results2`}
>
{ariaLive2}
</div>
</div>
);
}
}
ComboBox.propTypes = {
formData: PropTypes.string,
idSchema: PropTypes.object,
uiSchema: PropTypes.object,
onChange: PropTypes.func,
};