Skip to content

Commit

Permalink
[WC-2151]: Combobox screenreader a11y (#774)
Browse files Browse the repository at this point in the history
  • Loading branch information
rahmanunver authored Nov 20, 2023
2 parents 4133ee6 + f2a6acc commit d5aed92
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,19 @@ export const preview = (props: ComboboxPreviewProps): ReactElement => {
const commonProps = {
tabIndex: 1,
inputId: id,
labelId: `${id}-label`
labelId: `${id}-label`,
a11yConfig: {
ariaLabels: {
clearSelection: props.clearButtonAriaLabel,
removeSelection: props.removeValueAriaLabel
},
a11yStatusMessage: {
a11ySelectedValue: props.a11ySelectedValue,
a11yOptionsAvailable: props.a11yOptionsAvailable,
a11yInstructions: props.a11yInstructions,
a11yNoOption: props.noOptionsText
}
}
};

// eslint-disable-next-line react-hooks/rules-of-hooks
Expand Down
14 changes: 11 additions & 3 deletions packages/pluggableWidgets/combobox-web/src/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ export default function Combobox(props: ComboboxContainerProps): ReactElement {
inputId: props.id,
labelId: `${props.id}-label`,
noOptionsText: props.noOptionsText?.value,
clearButtonAriaLabels: {
clearSelection: props.clearButtonAriaLabel,
removeSelection: props.removeValueAriaLabel
a11yConfig: {
ariaLabels: {
clearSelection: props.clearButtonAriaLabel,
removeSelection: props.removeValueAriaLabel
},
a11yStatusMessage: {
a11ySelectedValue: props.a11ySelectedValue?.value as string,
a11yOptionsAvailable: props.a11yOptionsAvailable?.value as string,
a11yInstructions: props.a11yInstructions?.value as string,
a11yNoOption: props.noOptionsText?.value as string
}
}
};

Expand Down
25 changes: 24 additions & 1 deletion packages/pluggableWidgets/combobox-web/src/Combobox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,30 @@
</property>
<property key="removeValueAriaLabel" type="string" defaultValue="Remove value" required="true">
<caption>Remove value</caption>
<description>Used for the individual selected value labels in multi-selection</description>
<description>Used for the individual selected value labels in multi-selection.</description>
</property>
</propertyGroup>
<propertyGroup caption="Accessibility Status Message ">
<property key="a11ySelectedValue" type="textTemplate" required="true">
<caption>Selected value</caption>
<description>Output example: "Selected value: Avocado, Apple, Banana."</description>
<translations>
<translation lang="en_US">Selected value:</translation>
</translations>
</property>
<property key="a11yOptionsAvailable" type="textTemplate" required="true">
<caption>Options available</caption>
<description>Output example: "Number of options available: 1"</description>
<translations>
<translation lang="en_US">Number of options available:</translation>
</translations>
</property>
<property key="a11yInstructions" type="textTemplate" required="true">
<caption>Instructions</caption>
<description>Instructions to be read after announcing the status.</description>
<translations>
<translation lang="en_US">Use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.</translation>
</translations>
</property>
</propertyGroup>
</propertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ describe("Combo box (Association)", () => {
noOptionsText: dynamicValue("no options found"),
clearButtonAriaLabel: "Clear selection",
removeValueAriaLabel: "Remove value",
selectionMethod: "checkbox"
selectionMethod: "checkbox",
a11ySelectedValue: dynamicValue("Selected value:"),
a11yOptionsAvailable: dynamicValue("Options available:"),
a11yInstructions: dynamicValue("a11yInstructions")
};
if (defaultProps.optionsSourceAssociationCaptionType === "expression") {
defaultProps.optionsSourceAssociationCaptionExpression!.get = i => {
Expand All @@ -62,10 +65,10 @@ describe("Combo box (Association)", () => {
const { container } = render(<Combobox {...defaultProps} />);
expect(container.getElementsByClassName("widget-combobox-placeholder")).toHaveLength(1);
});
it("toggles combobox menu on: input FOCUS / BLUR", async () => {
it("toggles combobox menu on: input CLICK(focus) / BLUR", async () => {
const component = render(<Combobox {...defaultProps} />);
const toggleButton = await component.findByRole("combobox");
fireEvent.focus(toggleButton);
fireEvent.click(toggleButton);
expect(component.getAllByRole("option")).toHaveLength(4);
fireEvent.blur(toggleButton);
expect(component.queryAllByRole("option")).toHaveLength(0);
Expand All @@ -81,7 +84,7 @@ describe("Combo box (Association)", () => {
it("adds new item to inital selected item", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = (await component.findByRole("combobox")) as HTMLInputElement;
fireEvent.focus(input);
fireEvent.click(input);
const option1 = await component.findByText("222");
fireEvent.click(option1);
expect(component.container).toMatchSnapshot();
Expand All @@ -93,7 +96,7 @@ describe("Combo box (Association)", () => {
const component = render(<Combobox {...defaultProps} />);

const input = (await component.findByRole("combobox")) as HTMLInputElement;
fireEvent.focus(input);
fireEvent.click(input);

const option1 = await component.findByText("222");
fireEvent.click(option1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ describe("Combo box (Association)", () => {
selectedItemsStyle: "text",
clearButtonAriaLabel: "Clear selection",
removeValueAriaLabel: "Remove value",
selectionMethod: "checkbox"
selectionMethod: "checkbox",
a11ySelectedValue: dynamicValue("Selected value:"),
a11yOptionsAvailable: dynamicValue("Options available:"),
a11yInstructions: dynamicValue("a11yInstructions")
};
if (defaultProps.optionsSourceAssociationCaptionType === "expression") {
defaultProps.optionsSourceAssociationCaptionExpression!.get = i => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { MultiSelectionMenu } from "./MultiSelectionMenu";
export function MultiSelection({
selector,
tabIndex,
clearButtonAriaLabels,
a11yConfig,
...options
}: SelectionBaseProps<MultiSelector>): ReactElement {
const {
Expand All @@ -29,7 +29,7 @@ export function MultiSelection({
items,
setSelectedItems,
toggleSelectedItem
} = useDownshiftMultiSelectProps(selector, options);
} = useDownshiftMultiSelectProps(selector, options, a11yConfig.a11yStatusMessage);
const inputRef = useRef<HTMLInputElement>(null);
const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes";

Expand Down Expand Up @@ -61,7 +61,7 @@ export function MultiSelection({
{selector.caption.render(selectedItemForRender, "label")}
<span
className="icon widget-combobox-clear-button"
aria-label={clearButtonAriaLabels?.removeSelection}
aria-label={a11yConfig.ariaLabels?.removeSelection}
role="button"
onClick={e => {
e.stopPropagation();
Expand All @@ -87,7 +87,6 @@ export function MultiSelection({
{ suppressRefError: true }
),
ref: inputRef,
onClick: e => e.stopPropagation(),
onKeyDown: (event: KeyboardEvent) => {
if (
(event.key === "Backspace" && inputRef.current?.selectionStart === 0) ||
Expand Down Expand Up @@ -121,7 +120,7 @@ export function MultiSelection({
<button
tabIndex={tabIndex}
className="widget-combobox-clear-button"
aria-label={clearButtonAriaLabels?.clearSelection}
aria-label={a11yConfig.ariaLabels?.clearSelection}
onClick={e => {
e.stopPropagation();
inputRef.current?.focus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { SingleSelectionMenu } from "./SingleSelectionMenu";
export function SingleSelection({
selector,
tabIndex = 0,
clearButtonAriaLabels,
a11yConfig,
...options
}: SelectionBaseProps<SingleSelector>): ReactElement {
const {
Expand All @@ -22,7 +22,7 @@ export function SingleSelection({
reset,
isOpen,
highlightedIndex
} = useDownshiftSingleSelectProps(selector, options);
} = useDownshiftSingleSelectProps(selector, options, a11yConfig.a11yStatusMessage);
const inputRef = useRef<HTMLInputElement>(null);
return (
<Fragment>
Expand Down Expand Up @@ -63,7 +63,7 @@ export function SingleSelection({
<button
tabIndex={tabIndex}
className="widget-combobox-clear-button"
aria-label={clearButtonAriaLabels?.clearSelection}
aria-label={a11yConfig.ariaLabels?.clearSelection}
onClick={e => {
e.stopPropagation();
inputRef.current?.focus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function SingleSelectionMenu({
return (
<ComboboxMenuWrapper
isOpen={isOpen}
isEmpty={items.length <= 0}
isEmpty={items?.length <= 0}
getMenuProps={getMenuProps}
noOptionsText={noOptionsText}
>
Expand Down
16 changes: 13 additions & 3 deletions packages/pluggableWidgets/combobox-web/src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,18 @@ export interface SelectionBaseProps<Selector> {
inputId: string;
labelId?: string;
noOptionsText?: string;
clearButtonAriaLabels?: {
clearSelection: string;
removeSelection: string;
a11yConfig: {
ariaLabels: {
clearSelection: string;
removeSelection: string;
};
a11yStatusMessage: A11yStatusMessage;
};
}

export interface A11yStatusMessage {
a11ySelectedValue: string;
a11yOptionsAvailable: string;
a11yInstructions: string;
a11yNoOption: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useMultipleSelection
} from "downshift";
import { useMemo } from "react";
import { MultiSelector } from "../helpers/types";
import { A11yStatusMessage, MultiSelector } from "../helpers/types";

export type UseDownshiftMultiSelectPropsReturnValue = UseMultipleSelectionReturnValue<string> &
Pick<
Expand All @@ -34,7 +34,8 @@ interface Options {

export function useDownshiftMultiSelectProps(
selector: MultiSelector,
options?: Options
options: Options,
a11yStatusMessage: A11yStatusMessage
): UseDownshiftMultiSelectPropsReturnValue {
const {
getSelectedItemProps,
Expand All @@ -47,6 +48,10 @@ export function useDownshiftMultiSelectProps(
addSelectedItem
} = useMultipleSelection({
selectedItems: selector.currentValue ?? [],
itemToString: (v: string) => selector.caption.get(v),
getA11yRemovalMessage(options) {
return `${options.itemToString(options.removedSelectedItem)} has been removed.`;
},
onSelectedItemsChange({ selectedItems }) {
selector.setValue(selectedItems ?? []);
},
Expand Down Expand Up @@ -77,7 +82,17 @@ export function useDownshiftMultiSelectProps(
getItemProps,
inputValue,
setInputValue
} = useCombobox(useComboboxProps(selector, selectedItems, items, removeSelectedItem, setSelectedItems, options));
} = useCombobox(
useComboboxProps(
selector,
selectedItems,
items,
removeSelectedItem,
setSelectedItems,
a11yStatusMessage,
options
)
);

const toggleSelectedItem = (index: number): void => {
const item = items[index];
Expand Down Expand Up @@ -121,6 +136,7 @@ function useComboboxProps(
items: string[],
removeSelectedItem: (item: string) => void,
setSelectedItems: (item: string[]) => void,
a11yStatusMessage: A11yStatusMessage,
options?: Options
): UseComboboxProps<string> {
return useMemo(() => {
Expand All @@ -132,16 +148,39 @@ function useComboboxProps(
onInputValueChange({ inputValue }) {
selector.options.setSearchTerm(inputValue!);
},
getA11yStatusMessage(options) {
let message =
selectedItems.length > 0
? `${a11yStatusMessage.a11ySelectedValue} ${selectedItems
.map(itemId => selector.caption.get(itemId))
.join(",")}. `
: "";
if (!options.resultCount) {
return a11yStatusMessage.a11yNoOption;
}
if (!options.isOpen) {
return message;
}
if (options.previousResultCount !== options.resultCount || !options.highlightedItem) {
message += `${a11yStatusMessage.a11yOptionsAvailable} ${options.resultCount}. ${a11yStatusMessage.a11yInstructions}`;
}

return message;
},
itemToString: (v: string | null) => selector.caption.get(v),
stateReducer(_state: UseComboboxState<string>, actionAndChanges: UseComboboxStateChangeOptions<string>) {
stateReducer(state: UseComboboxState<string>, actionAndChanges: UseComboboxStateChangeOptions<string>) {
const { changes, type } = actionAndChanges;
switch (type) {
case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem:
return {
...changes,
inputValue: _state.inputValue
inputValue: state.inputValue
};
case useCombobox.stateChangeTypes.InputFocus:
return {
...changes,
isOpen: state.isOpen
};

case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
return {
Expand Down Expand Up @@ -183,5 +222,16 @@ function useComboboxProps(
};
// disable eslint rule as probably we should update props whenever currentValue changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selector, selectedItems, items, selector.currentValue, removeSelectedItem, setSelectedItems]);
}, [
selector,
selectedItems,
items,
selector.currentValue,
removeSelectedItem,
setSelectedItems,
a11yStatusMessage.a11ySelectedValue,
a11yStatusMessage.a11yOptionsAvailable,
a11yStatusMessage.a11yNoOption,
a11yStatusMessage.a11yInstructions
]);
}
Loading

0 comments on commit d5aed92

Please sign in to comment.