Skip to content

Commit 8c2d07f

Browse files
committed
fix: improve combobox aria-label handling and selection announcing
1 parent d788c10 commit 8c2d07f

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 { useFloatingMenu } from "../../hooks/useFloatingMenu";
813
import { useLazyLoading } from "../../hooks/useLazyLoading";
@@ -45,10 +50,7 @@ export function MultiSelection({
4550
const { refs, floatingStyles } = useFloatingMenu(keepMenuOpen === true ? false : isOpen);
4651
const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes";
4752
const isOptionsSelected = selector.isOptionsSelected();
48-
const inputLabel = getInputLabel(options.inputId);
4953
const errorId = getValidationErrorId(options.inputId);
50-
const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]);
51-
const labelText = inputLabel?.textContent?.trim() || "";
5254
const inputProps = getInputProps({
5355
...getDropdownProps(
5456
{
@@ -74,8 +76,7 @@ export function MultiSelection({
7476
},
7577
disabled: selector.readOnly,
7678
readOnly: selector.options.filterType === "none",
77-
"aria-required": ariaRequired.value,
78-
"aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined
79+
"aria-required": ariaRequired.value
7980
});
8081

8182
const memoizedselectedCaptions = useMemo(
@@ -96,6 +97,20 @@ export function MultiSelection({
9697
readOnly: selector.readOnly
9798
});
9899

100+
const inputLabel = getInputLabel(options.inputId);
101+
const ariaLabels = useMemo(
102+
() =>
103+
getComboboxAriaLabels({
104+
isOpen,
105+
hasSelection: selectedItems.length > 0,
106+
selectedValue: selectedItems.map(id => selector.caption.get(id)).join(", "),
107+
inputLabel,
108+
labelledBy: inputProps["aria-labelledby"],
109+
fallbackAriaLabel: options.ariaLabel
110+
}),
111+
[isOpen, selectedItems, inputLabel, inputProps, options.ariaLabel, selector.caption]
112+
);
113+
99114
return (
100115
<Fragment>
101116
<ComboboxWrapper
@@ -150,16 +165,8 @@ export function MultiSelection({
150165
tabIndex={tabIndex}
151166
placeholder=" "
152167
{...inputProps}
153-
aria-label={
154-
!isOpen && selectedItems.length > 0 && hasLabel
155-
? `${labelText}, ${selectedItems.map(id => selector.caption.get(id)).join(", ")}`
156-
: !isOpen && selectedItems.length > 0
157-
? selectedItems.map(id => selector.caption.get(id)).join(", ")
158-
: !hasLabel
159-
? options.ariaLabel
160-
: undefined
161-
}
162-
aria-labelledby={hasLabel && isOpen ? inputProps["aria-labelledby"] : undefined}
168+
aria-label={ariaLabels.ariaLabel}
169+
aria-labelledby={ariaLabels.ariaLabelledBy}
163170
aria-describedby={selector.validation ? errorId : undefined}
164171
aria-invalid={selector.validation ? true : undefined}
165172
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 { useFloatingMenu } from "../../hooks/useFloatingMenu";
88
import { useLazyLoading } from "../../hooks/useLazyLoading";
@@ -59,10 +59,7 @@ export function SingleSelection({
5959
]
6060
);
6161

62-
const inputLabel = getInputLabel(options.inputId);
6362
const errorId = getValidationErrorId(options.inputId);
64-
const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]);
65-
const labelText = inputLabel?.textContent?.trim() || "";
6663
const onInputKeyDown = useMemo<KeyboardEventHandler<HTMLInputElement> | undefined>(() => {
6764
if (!selector.clearable) {
6865
return undefined;
@@ -81,11 +78,25 @@ export function SingleSelection({
8178
readOnly: selector.options.filterType === "none",
8279
ref: inputRef,
8380
"aria-required": ariaRequired.value,
84-
"aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined,
8581
onKeyDown: onInputKeyDown
8682
},
8783
{ suppressRefError: true }
8884
);
85+
86+
const inputLabel = getInputLabel(options.inputId);
87+
const ariaLabels = useMemo(
88+
() =>
89+
getComboboxAriaLabels({
90+
isOpen,
91+
hasSelection: Boolean(selectedItem),
92+
selectedValue: selectedItem ? selector.caption.get(selectedItem) : "",
93+
inputLabel,
94+
labelledBy: inputProps["aria-labelledby"],
95+
fallbackAriaLabel: options.ariaLabel
96+
}),
97+
[isOpen, selectedItem, inputLabel, inputProps, options.ariaLabel, selector.caption]
98+
);
99+
89100
return (
90101
<Fragment>
91102
<ComboboxWrapper
@@ -111,16 +122,8 @@ export function SingleSelection({
111122
tabIndex={tabIndex}
112123
{...inputProps}
113124
placeholder=" "
114-
aria-label={
115-
!isOpen && selectedItem && hasLabel
116-
? `${labelText}, ${selector.caption.get(selectedItem)}`
117-
: !isOpen && selectedItem
118-
? selector.caption.get(selectedItem)
119-
: !hasLabel
120-
? options.ariaLabel
121-
: undefined
122-
}
123-
aria-labelledby={hasLabel && isOpen ? inputProps["aria-labelledby"] : undefined}
125+
aria-label={ariaLabels.ariaLabel}
126+
aria-labelledby={ariaLabels.ariaLabelledBy}
124127
aria-describedby={selector.validation ? errorId : undefined}
125128
aria-invalid={selector.validation ? true : undefined}
126129
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)