Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix(ComboBox): fix(react-combobox): Remove _getAriaActiveDescendantValue, compute aria-activedescendantvalue in state, and update currentPendingValue when the options change.",
"packageName": "@fluentui/react",
"email": "[email protected]",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3942,6 +3942,7 @@ export interface IComboBoxProps extends ISelectableDroppableTextProps<IComboBox,

// @public (undocumented)
export interface IComboBoxState {
ariaActiveDescendantValue?: string;
currentPendingValue?: string;
currentPendingValueValidIndex: number;
currentPendingValueValidIndexOnHover: number;
Expand Down
116 changes: 71 additions & 45 deletions packages/react/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface IComboBoxState {

/** When taking input, this will store the actual text that is being entered */
currentPendingValue?: string;

/**
* The id of the current focused combo item, otherwise the id of the currently selected element,
* null otherwise
*/
ariaActiveDescendantValue?: string;
}

enum SearchDirection {
Expand Down Expand Up @@ -347,9 +353,9 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
text,
onMenuOpen,
onMenuDismissed,
hoisted: { selectedIndices },
hoisted: { currentOptions, selectedIndices },
} = this.props;
const { isOpen, currentPendingValueValidIndex } = this.state;
const { currentPendingValue, currentPendingValueValidIndex, isOpen } = this.state;

// If we are newly open or are open and the pending valid index changed,
// make sure the currently selected/pending option is scrolled into view
Expand Down Expand Up @@ -404,6 +410,33 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
if (!isOpen && prevState.isOpen && onMenuDismissed) {
onMenuDismissed();
}

let newCurrentPendingValueValidIndex = currentPendingValueValidIndex;
const options = currentOptions.map((item, index) => ({ ...item, index }));

// If currentOptions differs from the previous currentOptions we need to update the currentPendingValueValidIndex
// otherwise, it will be out of sync with the currentOptions. This can happen when the options are filtered.
if (!shallowCompare(prevProps.hoisted.currentOptions, currentOptions) && currentPendingValue) {
newCurrentPendingValueValidIndex =
this.props.allowFreeform || this.props.allowFreeInput
? this._processInputChangeWithFreeform(currentPendingValue)
: this._updateAutocompleteIndexWithoutFreeform(currentPendingValue);
}

let descendantText = undefined;

if (isOpen && selectedIndices.length) {
descendantText = options[selectedIndices[0]]?.id ?? this._id + '-list' + selectedIndices[0];
} else if (isOpen && this._hasFocus() && newCurrentPendingValueValidIndex !== -1) {
descendantText =
options[newCurrentPendingValueValidIndex].id ?? this._id + '-list' + newCurrentPendingValueValidIndex;
}

if (descendantText !== this.state.ariaActiveDescendantValue) {
this.setState({
ariaActiveDescendantValue: descendantText,
});
}
}

public componentWillUnmount(): void {
Expand Down Expand Up @@ -579,7 +612,7 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
hoisted: { suggestedDisplayValue },
} = this.props;

const { isOpen } = this.state;
const { ariaActiveDescendantValue, isOpen } = this.state;

// If the combo box has focus, is multiselect, and has a display string, then use that placeholder
// so that the selected items don't appear to vanish. This is not ideal but it's the only reasonable way
Expand Down Expand Up @@ -623,7 +656,7 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
aria-describedby={
errorMessage !== undefined ? mergeAriaAttributeValues(ariaDescribedBy, errorMessageId) : ariaDescribedBy
}
aria-activedescendant={this._getAriaActiveDescendantValue()}
aria-activedescendant={ariaActiveDescendantValue}
aria-required={required}
aria-disabled={disabled}
aria-controls={isOpen ? this._id + '-list' : undefined}
Expand Down Expand Up @@ -801,8 +834,9 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
/**
* Process the new input's new value when the combo box allows freeform entry
* @param updatedValue - the input's newly changed value
* @returns the index of the matched option, -1 if no match was found
*/
private _processInputChangeWithFreeform(updatedValue: string): void {
private _processInputChangeWithFreeform(updatedValue: string): number {
const { currentOptions } = this.props.hoisted;
let newCurrentPendingValueValidIndex = -1;

Expand All @@ -818,7 +852,7 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
}

this._setPendingInfo(updatedValue, newCurrentPendingValueValidIndex, updatedValue);
return;
return newCurrentPendingValueValidIndex;
}

// Remember the original value and then make the value lowercase for comparison
Expand Down Expand Up @@ -865,14 +899,15 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox

// Set the updated state
this._setPendingInfo(originalUpdatedValue, newCurrentPendingValueValidIndex, newSuggestedDisplayValue);
return newCurrentPendingValueValidIndex;
}

/**
* Process the new input's new value when the combo box does not allow freeform entry
* @param updatedValue - the input's newly changed value
* @returns the index of the matched option
*/
private _processInputChangeWithoutFreeform(updatedValue: string): void {
const { currentOptions } = this.props.hoisted;
private _processInputChangeWithoutFreeform(updatedValue: string): number {
const { currentPendingValue, currentPendingValueValidIndex } = this.state;

if (this.props.autoComplete === 'on') {
Expand All @@ -892,32 +927,18 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
updatedValue = normalizeToString(currentPendingValue) + updatedValue;
}

const originalUpdatedValue: string = updatedValue;
updatedValue = updatedValue.toLocaleLowerCase();

// If autoComplete is on, attempt to find a match where the text of an option starts with the updated value
const items = currentOptions
.map((item, i) => ({ ...item, index: i }))

.filter(
option =>
isNormalOption(option) && !option.disabled && option.text.toLocaleLowerCase().indexOf(updatedValue) === 0,
);

// If we found a match, update the state
if (items.length > 0) {
this._setPendingInfo(originalUpdatedValue, items[0].index, getPreviewText(items[0]));
}
const matchingIndex = this._updateAutocompleteIndexWithoutFreeform(updatedValue);

// Schedule a timeout to clear the pending value after the timeout span
this._autoCompleteTimeout = this._async.setTimeout(() => {
this._autoCompleteTimeout = undefined;
}, ReadOnlyPendingAutoCompleteTimeout);
return;

return matchingIndex;
}
}

// If we get here, either autoComplete is off or we did not find a match with autoComplete on.
// If we get here, autoComplete is off.
// Remember we are not allowing freeform, so at this point, if we have a pending valid value index
// use that; otherwise use the selectedIndex
const index = currentPendingValueValidIndex >= 0 ? currentPendingValueValidIndex : this._getFirstSelectedIndex();
Expand All @@ -927,6 +948,30 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
// to allow us to select all content in the input to
// give the illusion that we are readonly (e.g. freeform off)
this._setPendingInfoFromIndex(index);
return index;
}

private _updateAutocompleteIndexWithoutFreeform(updatedValue: string): number {
const { currentOptions } = this.props.hoisted;
const originalUpdatedValue: string = updatedValue;
updatedValue = updatedValue.toLocaleLowerCase();

// If autoComplete is on, attempt to find a match where the text of an option starts with the updated value
const items = currentOptions
.map((item, i) => ({ ...item, index: i }))

.filter(
option =>
isNormalOption(option) && !option.disabled && option.text.toLocaleLowerCase().indexOf(updatedValue) === 0,
);

// If we found a match, update the state
if (items.length > 0) {
this._setPendingInfo(originalUpdatedValue, items[0].index, getPreviewText(items[0]));
return items[0].index;
}

return -1;
}

private _getFirstSelectedIndex(): number {
Expand Down Expand Up @@ -2377,25 +2422,6 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
);
}

/**
* Get the aria-activedescendant value for the combo box.
* @returns the id of the current focused combo item, otherwise the id of the currently selected element,
* null otherwise
*/
private _getAriaActiveDescendantValue(): string | undefined {
const { currentOptions, selectedIndices } = this.props.hoisted;
const { isOpen, currentPendingValueValidIndex } = this.state;
const options = currentOptions.map((item, index) => ({ ...item, index }));
let descendantText =
isOpen && selectedIndices?.length
? options[selectedIndices[0]].id ?? this._id + '-list' + selectedIndices[0]
: undefined;
if (isOpen && this._hasFocus() && currentPendingValueValidIndex !== -1) {
descendantText = options[currentPendingValueValidIndex].id ?? this._id + '-list' + currentPendingValueValidIndex;
}
return descendantText;
}

/**
* Get the aria autocomplete value for the combo box
* @returns 'inline' if auto-complete automatically dynamic, 'both' if we have a list of possible values to pick from
Expand Down