Skip to content
Closed
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": "Apply picker accessibility improvement to version 7.0",
"packageName": "@fluentui/react-examples",
"email": "suti@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Apply picker accessibility improvement to version 7.0",
"packageName": "office-ui-fabric-react",
"email": "suti@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { IComponentAs } from '@uifabric/utilities';
import { IComponentStyles } from '@uifabric/foundation';
import { ICSSPixelUnitRule } from '@uifabric/merge-styles/lib/IRawStyleBase';
import { ICSSRule } from '@uifabric/merge-styles/lib/IRawStyleBase';
import { IFocusZone } from '@fluentui/react-focus';
import { IFocusZoneProps } from '@fluentui/react-focus';
import { IFontStyles } from '@uifabric/styling';
import { IHTMLSlot } from '@uifabric/foundation';
Expand Down Expand Up @@ -304,8 +303,6 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
// (undocumented)
focusInput(): void;
// (undocumented)
protected focusZone: React.RefObject<IFocusZone>;
// (undocumented)
protected getActiveDescendant(): string | undefined;
// (undocumented)
static getDerivedStateFromProps(newProps: IBasePickerProps<any>): {
Expand All @@ -326,6 +323,8 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
protected onClick: (ev: React.MouseEvent<HTMLInputElement, MouseEvent>) => void;
protected onEmptyInputFocus(): void;
// (undocumented)
protected onFocus: () => void;
// (undocumented)
protected onGetMoreResults: () => void;
// (undocumented)
protected onInputBlur: (ev: React.FocusEvent<HTMLInputElement | Autofill>) => void;
Expand Down Expand Up @@ -365,7 +364,7 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
protected root: React.RefObject<HTMLDivElement>;
// (undocumented)
protected selection: Selection;
// (undocumented)
// @deprecated (undocumented)
protected _shouldFocusZoneEnterInnerZone: (ev: React.KeyboardEvent<HTMLElement>) => boolean;
// (undocumented)
protected suggestionElement: React.RefObject<ISuggestions<T>>;
Expand Down Expand Up @@ -1770,6 +1769,8 @@ export interface IBasePickerProps<T> extends React.Props<any> {
input: string;
}) => string) | string;
selectedItems?: T[];
selectionAriaLabel?: string;
selectionRole?: string;
styles?: IStyleFunctionOrObject<IBasePickerStyleProps, IBasePickerStyles>;
theme?: ITheme;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,4 +517,91 @@ describe('BasePicker', () => {
expect(moreButton.id).toEqual('sug-selectedAction');
expect(input.getAttribute('aria-activedescendant')).toEqual('sug-selectedAction');
});

it('focuses the input when the focus method is called', () => {
document.body.appendChild(root);

const picker = React.createRef<IBasePicker<ISimple>>();

ReactDOM.render(
<BasePickerWithType
componentRef={picker}
onResolveSuggestions={onResolveSuggestions}
onRenderItem={onRenderItem}
onRenderSuggestionsItem={basicSuggestionRenderer}
/>,
root,
);

const input = document.querySelector('.ms-BasePicker-input') as HTMLInputElement;
picker.current?.focus();

expect(document.activeElement).toBe(input);
});

it('focuses the last selected item after removing input', () => {
jest.useFakeTimers();
document.body.appendChild(root);

const onRenderFocusableItem = (props: IPickerItemProps<ISimple>): JSX.Element => (
<button key={props.item.name} data-selection-index={props.index}>
{basicRenderer(props)}
</button>
);
ReactDOM.render(
<BasePickerWithType
onResolveSuggestions={onResolveSuggestions}
onRenderItem={onRenderFocusableItem}
onRenderSuggestionsItem={basicSuggestionRenderer}
itemLimit={1}
/>,
root,
);

const input = document.querySelector('.ms-BasePicker-input') as HTMLInputElement;
input.focus();
input.value = 'bl';
ReactTestUtils.Simulate.input(input);
jest.runAllTimers();

const suggestionOptions = document.querySelectorAll('.ms-Suggestions-itemButton');
ReactTestUtils.Simulate.click(suggestionOptions[0]);

const selectedItem = document.querySelector('button[data-selection-index]');

expect(document.activeElement).toBe(selectedItem);
});

it('focuses the next selected item after removing a selection', () => {
jest.useFakeTimers();
document.body.appendChild(root);

const onRenderFocusableItem = (props: IPickerItemProps<ISimple>): JSX.Element => {
return (
<button key={props.item.name} onClick={props.onRemoveItem} data-selection-index={props.index}>
{basicRenderer(props)}
</button>
);
};
ReactDOM.render(
<BasePickerWithType
defaultSelectedItems={[
{ key: '1', name: 'blue' },
{ key: '2', name: 'black' },
]}
onResolveSuggestions={onResolveSuggestions}
onRenderItem={onRenderFocusableItem}
onRenderSuggestionsItem={basicSuggestionRenderer}
/>,
root,
);

const selectedEls = document.querySelectorAll('button[data-selection-index]');
(selectedEls[0] as HTMLButtonElement).focus();
ReactTestUtils.Simulate.click(selectedEls[0]);

jest.runAllTimers();

expect(document.activeElement).toBe(selectedEls[1]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
initializeComponentRef,
} from '../../Utilities';
import { IProcessedStyleSet } from '../../Styling';
import { IFocusZone, FocusZone, FocusZoneDirection } from '../../FocusZone';
import { Callout, DirectionalHint } from '../../Callout';
import { Selection, SelectionZone, SelectionMode } from '../../utilities/selection/index';
import { Suggestions } from './Suggestions/Suggestions';
Expand Down Expand Up @@ -94,7 +93,6 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
// Refs
protected root = React.createRef<HTMLDivElement>();
protected input = React.createRef<IAutofill>();
protected focusZone = React.createRef<IFocusZone>();
protected suggestionElement = React.createRef<ISuggestions<T>>();

protected selection: Selection;
Expand Down Expand Up @@ -166,6 +164,10 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
this.selection.setIndexSelected(currentSelectedIndex, true, true);
this.resetFocus(currentSelectedIndex);
}
// Reset focus to last item if the input is removed
else if (this.state.items.length > oldState.items.length && !this.canAddItems()) {
this.resetFocus(this.state.items.length - 1);
}
}
}
}
Expand All @@ -178,8 +180,8 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
}

public focus() {
if (this.focusZone.current) {
this.focusZone.current.focus();
if (this.input.current) {
this.input.current.focus();
}
}

Expand Down Expand Up @@ -242,7 +244,7 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen

public render(): JSX.Element {
const { suggestedDisplayValue, isFocused, items } = this.state;
const { className, inputProps, disabled, theme, styles } = this.props;
const { className, inputProps, disabled, selectionAriaLabel, selectionRole = 'list', theme, styles } = this.props;
const suggestionsAvailable = this.state.suggestionsVisible ? this._ariaMap.suggestionList : '';
// TODO
// Clean this up by leaving only the first part after removing support for SASS.
Expand All @@ -269,47 +271,61 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
screenReaderText: legacyStyles.screenReaderOnly,
};

const comboLabel = this.props['aria-label'] || inputProps?.['aria-label'];

// selectionAriaLabel is contained in a separate <span> rather than an aria-label on the items list
// because if the items list has an aria-label, the aria-describedby on the input will only read
// that label instead of all the selected items. Using aria-labelledby instead fixes this, since
// aria-describedby and aria-labelledby will not follow a second aria-labelledby
return (
<div ref={this.root} className={classNames.root} onKeyDown={this.onKeyDown} onBlur={this.onBlur}>
<FocusZone
componentRef={this.focusZone}
direction={FocusZoneDirection.bidirectional}
shouldEnterInnerZone={this._shouldFocusZoneEnterInnerZone}
>
{this.getSuggestionsAlert(classNames.screenReaderText)}
<SelectionZone selection={this.selection} selectionMode={SelectionMode.multiple}>
<div className={classNames.text}>
{items.length > 0 && (
<span id={this._ariaMap.selectedItems} className={classNames.itemsWrapper} role={'list'}>
{this.renderItems()}
</span>
)}
{this.canAddItems() && (
<Autofill
spellCheck={false}
{...(inputProps as any)}
className={classNames.input}
componentRef={this.input}
id={inputProps?.id ? inputProps.id : this._ariaMap.combobox}
onClick={this.onClick}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
onInputValueChange={this.onInputChange}
suggestedDisplayValue={suggestedDisplayValue}
aria-activedescendant={this.getActiveDescendant()}
aria-controls={suggestionsAvailable}
aria-describedby={items.length > 0 ? this._ariaMap.selectedItems : undefined}
aria-expanded={!!this.state.suggestionsVisible}
aria-haspopup="listbox"
aria-label={this.props['aria-label'] || inputProps?.['aria-label']}
role="combobox"
disabled={disabled}
onInputChange={this.props.onInputChange}
/>
)}
</div>
</SelectionZone>
</FocusZone>
<div
ref={this.root}
className={classNames.root}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
>
{this.getSuggestionsAlert(classNames.screenReaderText)}
<span id={`${this._ariaMap.selectedItems}-label`} hidden>
{selectionAriaLabel || comboLabel}
</span>
<SelectionZone selection={this.selection} selectionMode={SelectionMode.multiple}>
<div className={classNames.text}>
{items.length > 0 && (
<span
id={this._ariaMap.selectedItems}
className={classNames.itemsWrapper}
role={selectionRole}
aria-labelledby={`${this._ariaMap.selectedItems}-label`}
>
{this.renderItems()}
</span>
)}
{this.canAddItems() && (
<Autofill
spellCheck={false}
{...(inputProps as any)}
className={classNames.input}
componentRef={this.input}
id={inputProps?.id ? inputProps.id : this._ariaMap.combobox}
onClick={this.onClick}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
onInputValueChange={this.onInputChange}
suggestedDisplayValue={suggestedDisplayValue}
aria-activedescendant={this.getActiveDescendant()}
aria-controls={suggestionsAvailable}
aria-describedby={items.length > 0 ? this._ariaMap.selectedItems : undefined}
aria-expanded={!!this.state.suggestionsVisible}
aria-haspopup="listbox"
aria-label={comboLabel}
role="combobox"
disabled={disabled}
onInputChange={this.props.onInputChange}
/>
)}
</div>
</SelectionZone>
{this.renderSuggestions()}
</div>
);
Expand Down Expand Up @@ -385,8 +401,8 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
(this.root.current.querySelectorAll('[data-selection-index]')[
Math.min(index!, items.length - 1)
] as HTMLElement | null);
if (newEl && this.focusZone.current) {
this.focusZone.current.focusElement(newEl);
if (newEl) {
newEl.focus();
}
} else if (!this.canAddItems()) {
this.resetFocus(items.length - 1);
Expand Down Expand Up @@ -532,8 +548,6 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
// For example when an item is selected or removed from the selected list it should be treated
// as though the input is still focused.
if (!this.state.isFocused) {
this.setState({ isFocused: true });

this._userTriggeredSuggestions();

if (this.props.inputProps && this.props.inputProps.onFocus) {
Expand Down Expand Up @@ -589,6 +603,12 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
}
};

protected onFocus = () => {
if (!this.state.isFocused) {
this.setState({ isFocused: true });
}
};

protected onKeyDown = (ev: React.KeyboardEvent<HTMLElement>): void => {
const keyCode = ev.which;
switch (keyCode) {
Expand Down Expand Up @@ -824,6 +844,9 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
}
}

/**
* @deprecated this is no longer necessary as focuszone has been removed
*/
protected _shouldFocusZoneEnterInnerZone = (ev: React.KeyboardEvent<HTMLElement>): boolean => {
// If suggestions are shown const up/down keys control them, otherwise allow them through to control the focusZone.
if (this.state.suggestionsVisible) {
Expand Down Expand Up @@ -983,7 +1006,7 @@ export class BasePicker<T, P extends IBasePickerProps<T>> extends React.Componen
export class BasePickerListBelow<T, P extends IBasePickerProps<T>> extends BasePicker<T, P> {
public render(): JSX.Element {
const { suggestedDisplayValue, isFocused } = this.state;
const { className, inputProps, disabled, theme, styles } = this.props;
const { className, inputProps, disabled, selectionAriaLabel, selectionRole = 'list', theme, styles } = this.props;

const suggestionsAvailable: string | undefined = this.state.suggestionsVisible ? this._ariaMap.suggestionList : '';

Expand Down Expand Up @@ -1015,8 +1038,10 @@ export class BasePickerListBelow<T, P extends IBasePickerProps<T>> extends BaseP
screenReaderText: legacyStyles.screenReaderOnly,
};

const comboLabel = this.props['aria-label'] || inputProps?.['aria-label'];

return (
<div ref={this.root} onBlur={this.onBlur}>
<div ref={this.root} onBlur={this.onBlur} onFocus={this.onFocus}>
<div className={classNames.root} onKeyDown={this.onKeyDown}>
{this.getSuggestionsAlert(classNames.screenReaderText)}
<div className={classNames.text}>
Expand All @@ -1033,25 +1058,24 @@ export class BasePickerListBelow<T, P extends IBasePickerProps<T>> extends BaseP
aria-controls={suggestionsAvailable || undefined}
aria-expanded={!!this.state.suggestionsVisible}
aria-haspopup="listbox"
aria-label={comboLabel}
role="combobox"
id={inputProps?.id ? inputProps.id : this._ariaMap.combobox}
disabled={disabled}
onInputChange={this.props.onInputChange}
/>
</div>
</div>
{this.renderSuggestions()}
<SelectionZone selection={this.selection} selectionMode={SelectionMode.single}>
<FocusZone
componentRef={this.focusZone}
className="ms-BasePicker-selectedItems" // just a className hook without any styles applied to it.
isCircularNavigation={true}
direction={FocusZoneDirection.bidirectional}
shouldEnterInnerZone={this._shouldFocusZoneEnterInnerZone}
<div
id={this._ariaMap.selectedItems}
role={'list'}
className="ms-BasePicker-selectedItems" // just a className hook without any styles applied to it.
role={selectionRole}
aria-label={selectionAriaLabel || comboLabel}
>
{this.renderItems()}
</FocusZone>
</div>
</SelectionZone>
</div>
);
Expand Down
Loading