Skip to content

Commit 1a191df

Browse files
committed
fix: improve combobox aria-label handling and selection announcing
1 parent 77e7192 commit 1a191df

4 files changed

Lines changed: 88 additions & 32 deletions

File tree

packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import classNames from "classnames";
22
import { Fragment, KeyboardEvent, ReactElement, useMemo, useRef } from "react";
33
import { ClearButton } from "../../assets/icons";
44
import { MultiSelector, SelectionBaseProps } from "../../helpers/types";
5-
import { getInputLabel, getSelectedCaptionsPlaceholder, getValidationErrorId } from "../../helpers/utils";
5+
import {
6+
getComboboxAriaLabels,
7+
getInputLabel,
8+
getSelectedCaptionsPlaceholder,
9+
getValidationErrorId
10+
} from "../../helpers/utils";
611
import { useDownshiftMultiSelectProps } from "../../hooks/useDownshiftMultiSelectProps";
712
import { useLazyLoading } from "../../hooks/useLazyLoading";
813
import { ComboboxWrapper } from "../ComboboxWrapper";
@@ -37,10 +42,7 @@ export function MultiSelection({
3742
} = useDownshiftMultiSelectProps(selector, options, inputRef, a11yConfig.a11yStatusMessage);
3843
const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes";
3944
const isOptionsSelected = selector.isOptionsSelected();
40-
const inputLabel = getInputLabel(options.inputId);
4145
const errorId = getValidationErrorId(options.inputId);
42-
const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]);
43-
const labelText = inputLabel?.textContent?.trim() || "";
4446
const inputProps = getInputProps({
4547
...getDropdownProps(
4648
{
@@ -66,8 +68,7 @@ export function MultiSelection({
6668
},
6769
disabled: selector.readOnly,
6870
readOnly: selector.options.filterType === "none",
69-
"aria-required": ariaRequired.value,
70-
"aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined
71+
"aria-required": ariaRequired.value
7172
});
7273

7374
const memoizedselectedCaptions = useMemo(
@@ -88,6 +89,20 @@ export function MultiSelection({
8889
readOnly: selector.readOnly
8990
});
9091

92+
const inputLabel = getInputLabel(options.inputId);
93+
const ariaLabels = useMemo(
94+
() =>
95+
getComboboxAriaLabels({
96+
isOpen,
97+
hasSelection: selectedItems.length > 0,
98+
selectedValue: selectedItems.map(id => selector.caption.get(id)).join(", "),
99+
inputLabel,
100+
labelledBy: inputProps["aria-labelledby"],
101+
fallbackAriaLabel: options.ariaLabel
102+
}),
103+
[isOpen, selectedItems, inputLabel, inputProps, options.ariaLabel, selector.caption]
104+
);
105+
91106
return (
92107
<Fragment>
93108
<ComboboxWrapper
@@ -141,16 +156,8 @@ export function MultiSelection({
141156
tabIndex={tabIndex}
142157
placeholder=" "
143158
{...inputProps}
144-
aria-label={
145-
!isOpen && selectedItems.length > 0 && hasLabel
146-
? `${labelText}, ${selectedItems.map(id => selector.caption.get(id)).join(", ")}`
147-
: !isOpen && selectedItems.length > 0
148-
? selectedItems.map(id => selector.caption.get(id)).join(", ")
149-
: !hasLabel
150-
? options.ariaLabel
151-
: undefined
152-
}
153-
aria-labelledby={hasLabel && isOpen ? inputProps["aria-labelledby"] : undefined}
159+
aria-label={ariaLabels.ariaLabel}
160+
aria-labelledby={ariaLabels.ariaLabelledBy}
154161
aria-describedby={selector.validation ? errorId : undefined}
155162
aria-invalid={selector.validation ? true : undefined}
156163
aria-busy={selector.options.isLoading || undefined}

packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import classNames from "classnames";
22
import { Fragment, KeyboardEventHandler, ReactElement, useMemo, useRef } from "react";
33
import { ClearButton } from "../../assets/icons";
44
import { SelectionBaseProps, SingleSelector } from "../../helpers/types";
5-
import { getInputLabel, getValidationErrorId } from "../../helpers/utils";
5+
import { getComboboxAriaLabels, getInputLabel, getValidationErrorId } from "../../helpers/utils";
66
import { useDownshiftSingleSelectProps } from "../../hooks/useDownshiftSingleSelectProps";
77
import { useLazyLoading } from "../../hooks/useLazyLoading";
88
import { ComboboxWrapper } from "../ComboboxWrapper";
@@ -57,10 +57,7 @@ export function SingleSelection({
5757
]
5858
);
5959

60-
const inputLabel = getInputLabel(options.inputId);
6160
const errorId = getValidationErrorId(options.inputId);
62-
const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]);
63-
const labelText = inputLabel?.textContent?.trim() || "";
6461
const onInputKeyDown = useMemo<KeyboardEventHandler<HTMLInputElement> | undefined>(() => {
6562
if (!selector.clearable) {
6663
return undefined;
@@ -79,11 +76,25 @@ export function SingleSelection({
7976
readOnly: selector.options.filterType === "none",
8077
ref: inputRef,
8178
"aria-required": ariaRequired.value,
82-
"aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined,
8379
onKeyDown: onInputKeyDown
8480
},
8581
{ suppressRefError: true }
8682
);
83+
84+
const inputLabel = getInputLabel(options.inputId);
85+
const ariaLabels = useMemo(
86+
() =>
87+
getComboboxAriaLabels({
88+
isOpen,
89+
hasSelection: Boolean(selectedItem),
90+
selectedValue: selectedItem ? selector.caption.get(selectedItem) : "",
91+
inputLabel,
92+
labelledBy: inputProps["aria-labelledby"],
93+
fallbackAriaLabel: options.ariaLabel
94+
}),
95+
[isOpen, selectedItem, inputLabel, inputProps, options.ariaLabel, selector.caption]
96+
);
97+
8798
return (
8899
<Fragment>
89100
<ComboboxWrapper
@@ -108,16 +119,8 @@ export function SingleSelection({
108119
tabIndex={tabIndex}
109120
{...inputProps}
110121
placeholder=" "
111-
aria-label={
112-
!isOpen && selectedItem && hasLabel
113-
? `${labelText}, ${selector.caption.get(selectedItem)}`
114-
: !isOpen && selectedItem
115-
? selector.caption.get(selectedItem)
116-
: !hasLabel
117-
? options.ariaLabel
118-
: undefined
119-
}
120-
aria-labelledby={hasLabel && isOpen ? inputProps["aria-labelledby"] : undefined}
122+
aria-label={ariaLabels.ariaLabel}
123+
aria-labelledby={ariaLabels.ariaLabelledBy}
121124
aria-describedby={selector.validation ? errorId : undefined}
122125
aria-invalid={selector.validation ? true : undefined}
123126
aria-busy={selector.options.isLoading || undefined}

packages/pluggableWidgets/combobox-web/src/helpers/utils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,49 @@ export function getInputLabel(inputId: string): Element | null {
157157
export function getValidationErrorId(inputId?: string): string | undefined {
158158
return inputId ? inputId + "-validation-message" : undefined;
159159
}
160+
161+
/**
162+
* Computes aria-label and aria-labelledby values for combobox input element.
163+
*
164+
* Announcement logic:
165+
* - With selection (open or closed): Announce "name, selected value(s)"
166+
* - Without selection: Announce "name" only
167+
*
168+
* Name source:
169+
* - hasLabel = true: Use visible label via aria-labelledby
170+
* - hasLabel = false: Use fallback aria-label
171+
*
172+
* Note: aria-labelledby has the highest precedence in the ARIA spec, so when we want
173+
* aria-label to be announced, we must NOT set aria-labelledby at all.
174+
*/
175+
export function getComboboxAriaLabels(params: {
176+
isOpen: boolean;
177+
hasSelection: boolean;
178+
selectedValue: string;
179+
inputLabel: Element | null;
180+
labelledBy: string | undefined;
181+
fallbackAriaLabel?: string;
182+
}): { ariaLabel: string | undefined; ariaLabelledBy: string | undefined } {
183+
const { hasSelection, selectedValue, inputLabel, labelledBy, fallbackAriaLabel } = params;
184+
185+
const hasLabel = Boolean(inputLabel);
186+
const labelText = inputLabel?.textContent?.trim() || fallbackAriaLabel;
187+
188+
let ariaLabel: string | undefined;
189+
190+
// With selection: announce both name and selected value(s)
191+
if (hasSelection) {
192+
const name = hasLabel ? labelText : fallbackAriaLabel;
193+
ariaLabel = name ? `${name}, ${selectedValue}` : selectedValue;
194+
}
195+
// No visible label: always use fallback aria-label
196+
else if (!hasLabel) {
197+
ariaLabel = fallbackAriaLabel;
198+
}
199+
// Otherwise: use aria-labelledby for visible label (when no selection)
200+
201+
return {
202+
ariaLabel,
203+
ariaLabelledBy: ariaLabel ? undefined : hasLabel ? labelledBy : undefined
204+
};
205+
}

packages/pluggableWidgets/combobox-web/typings/ComboboxProps.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* WARNING: All changes made to this file will be overwritten
44
* @author Mendix Widgets Framework Team
55
*/
6-
import { ActionValue, DynamicValue, EditableValue, ListAttributeValue, ListExpressionValue, ListValue, ListWidgetValue, Option, ReferenceSetValue, ReferenceValue, SelectionMultiValue, SelectionSingleValue } from "mendix";
76
import { ComponentType, ReactNode } from "react";
7+
import { ActionValue, DynamicValue, EditableValue, ListValue, Option, ListAttributeValue, ListExpressionValue, ListWidgetValue, ReferenceValue, ReferenceSetValue, SelectionSingleValue, SelectionMultiValue } from "mendix";
88
import { Big } from "big.js";
99

1010
export type SourceEnum = "context" | "database" | "static";

0 commit comments

Comments
 (0)