From 0bebe9473d7eba2992aa7f6112d9cdf861a8fb3e Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 17 Jun 2025 15:56:36 +0200 Subject: [PATCH 1/8] feat(combobox-web): add custom hook to check for associated label in inputs --- .../combobox-web/src/hooks/useHasLabel.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts b/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts new file mode 100644 index 0000000000..e279364885 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +export function useHasLabel(inputId: string): boolean { + const [hasLabel, setHasLabel] = useState(false); + + useEffect(() => { + const label = document.querySelector(`label[for="${inputId}"]`); + + setHasLabel(!!label); + }, [inputId]); + + return hasLabel; +} From baa27e442b77b1bcd43ea683e282a4cdee84211c Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 17 Jun 2025 15:57:24 +0200 Subject: [PATCH 2/8] feat(combobox-web): integrate label check in MultiSelection and SingleSelection --- .../MultiSelection/MultiSelection.tsx | 60 ++++++++++--------- .../SingleSelection/SingleSelection.tsx | 24 +++++--- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx index 1b12725c9c..fa08d7d7f3 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx @@ -4,6 +4,7 @@ import { ClearButton } from "../../assets/icons"; import { MultiSelector, SelectionBaseProps } from "../../helpers/types"; import { getSelectedCaptionsPlaceholder } from "../../helpers/utils"; import { useDownshiftMultiSelectProps } from "../../hooks/useDownshiftMultiSelectProps"; +import { useHasLabel } from "../../hooks/useHasLabel"; import { useLazyLoading } from "../../hooks/useLazyLoading"; import { ComboboxWrapper } from "../ComboboxWrapper"; import { InputPlaceholder } from "../Placeholder"; @@ -37,6 +38,34 @@ export function MultiSelection({ const inputRef = useRef(null); const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes"; const isOptionsSelected = selector.isOptionsSelected(); + const inputProps = getInputProps({ + ...getDropdownProps( + { + preventKeyAction: isOpen + }, + { suppressRefError: true } + ), + ref: inputRef, + onKeyDown: (event: KeyboardEvent) => { + if ( + (event.key === "Backspace" && inputRef.current?.selectionStart === 0) || + (event.key === "ArrowLeft" && isSelectedItemsBoxStyle && inputRef.current?.selectionStart === 0) + ) { + setActiveIndex(selectedItems.length - 1); + } + if (event.key === " ") { + if (highlightedIndex >= 0) { + toggleSelectedItem(highlightedIndex); + event.preventDefault(); + event.stopPropagation(); + } + } + }, + disabled: selector.readOnly, + readOnly: selector.options.filterType === "none", + "aria-required": ariaRequired.value + }); + const hasLabel = useHasLabel(inputProps.id); const memoizedselectedCaptions = useMemo( () => getSelectedCaptionsPlaceholder(selector, selectedItems), @@ -106,35 +135,8 @@ export function MultiSelection({ })} tabIndex={tabIndex} placeholder=" " - {...getInputProps({ - ...getDropdownProps( - { - preventKeyAction: isOpen - }, - { suppressRefError: true } - ), - ref: inputRef, - onKeyDown: (event: KeyboardEvent) => { - if ( - (event.key === "Backspace" && inputRef.current?.selectionStart === 0) || - (event.key === "ArrowLeft" && - isSelectedItemsBoxStyle && - inputRef.current?.selectionStart === 0) - ) { - setActiveIndex(selectedItems.length - 1); - } - if (event.key === " ") { - if (highlightedIndex >= 0) { - toggleSelectedItem(highlightedIndex); - event.preventDefault(); - event.stopPropagation(); - } - } - }, - disabled: selector.readOnly, - readOnly: selector.options.filterType === "none", - "aria-required": ariaRequired.value - })} + {...inputProps} + aria-labelledby={hasLabel ? inputProps["aria-labelledby"] : undefined} /> {memoizedselectedCaptions} diff --git a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx index 47d64be1a5..322024c5b8 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx @@ -3,6 +3,7 @@ import { Fragment, ReactElement, createElement, useMemo, useRef } from "react"; import { ClearButton } from "../../assets/icons"; import { SelectionBaseProps, SingleSelector } from "../../helpers/types"; import { useDownshiftSingleSelectProps } from "../../hooks/useDownshiftSingleSelectProps"; +import { useHasLabel } from "../../hooks/useHasLabel"; import { useLazyLoading } from "../../hooks/useLazyLoading"; import { ComboboxWrapper } from "../ComboboxWrapper"; import { InputPlaceholder } from "../Placeholder"; @@ -44,6 +45,7 @@ export function SingleSelection({ const selectedItemCaption = useMemo( () => selector.caption.render(selectedItem, "label"), + // eslint-disable-next-line react-hooks/exhaustive-deps [ selectedItem, selector.status, @@ -54,6 +56,17 @@ export function SingleSelection({ ] ); + const inputProps = getInputProps( + { + disabled: selector.readOnly, + readOnly: selector.options.filterType === "none", + ref: inputRef, + "aria-required": ariaRequired.value + }, + { suppressRefError: true } + ); + const hasLabel = useHasLabel(inputProps.id); + return ( Date: Tue, 17 Jun 2025 16:26:12 +0200 Subject: [PATCH 3/8] test(combobox-web): update snapshots after a11y fix --- .../src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap | 3 --- .../src/__tests__/__snapshots__/SingleSelection.spec.tsx.snap | 1 - .../src/__tests__/__snapshots__/StaticSelection.spec.tsx.snap | 1 - 3 files changed, 5 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap index 4c3a62d324..e067d4a728 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap @@ -18,7 +18,6 @@ exports[`Combo box (Association) renders combobox widget 1`] = ` aria-autocomplete="list" aria-controls="downshift-0-menu" aria-expanded="false" - aria-labelledby="comboBox1-label" aria-required="true" autocomplete="off" class="widget-combobox-input" @@ -129,7 +128,6 @@ exports[`Combo box (Association) toggles combobox menu on: input CLICK(focus) / aria-autocomplete="list" aria-controls="downshift-2-menu" aria-expanded="false" - aria-labelledby="comboBox1-label" aria-required="true" autocomplete="off" class="widget-combobox-input" @@ -240,7 +238,6 @@ exports[`Combo box (Association) toggles combobox menu on: input TOGGLE BUTTON 1 aria-autocomplete="list" aria-controls="downshift-6-menu" aria-expanded="false" - aria-labelledby="comboBox1-label" aria-required="true" autocomplete="off" class="widget-combobox-input" diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/SingleSelection.spec.tsx.snap b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/SingleSelection.spec.tsx.snap index f5c22b8218..21eff47ec7 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/SingleSelection.spec.tsx.snap +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/SingleSelection.spec.tsx.snap @@ -18,7 +18,6 @@ exports[`Combo box (Association) renders combobox widget 1`] = ` aria-autocomplete="list" aria-controls="downshift-0-menu" aria-expanded="false" - aria-labelledby="comboBox1-label" aria-required="true" autocomplete="off" class="widget-combobox-input" diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/StaticSelection.spec.tsx.snap b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/StaticSelection.spec.tsx.snap index f933d6aa10..e6188ccc4b 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/StaticSelection.spec.tsx.snap +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/StaticSelection.spec.tsx.snap @@ -18,7 +18,6 @@ exports[`Combo box (Static values) renders combobox widget 1`] = ` aria-autocomplete="list" aria-controls="downshift-0-menu" aria-expanded="false" - aria-labelledby="comboBox1-label" aria-required="true" autocomplete="off" class="widget-combobox-input" From 8e72f274a1910562a82f042af05136425ab7700b Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 20 Jun 2025 14:43:39 +0200 Subject: [PATCH 4/8] chore(combobox-web): create utils getInputLabel and use it to check if has label --- .../components/MultiSelection/MultiSelection.tsx | 7 ++++--- .../components/SingleSelection/SingleSelection.tsx | 6 ++++-- .../combobox-web/src/helpers/utils.ts | 4 ++++ .../combobox-web/src/hooks/useHasLabel.ts | 13 ------------- 4 files changed, 12 insertions(+), 18 deletions(-) delete mode 100644 packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts diff --git a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx index fa08d7d7f3..2399949ceb 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx @@ -2,9 +2,8 @@ import classNames from "classnames"; import { Fragment, KeyboardEvent, ReactElement, createElement, useMemo, useRef } from "react"; import { ClearButton } from "../../assets/icons"; import { MultiSelector, SelectionBaseProps } from "../../helpers/types"; -import { getSelectedCaptionsPlaceholder } from "../../helpers/utils"; +import { getInputLabel, getSelectedCaptionsPlaceholder } from "../../helpers/utils"; import { useDownshiftMultiSelectProps } from "../../hooks/useDownshiftMultiSelectProps"; -import { useHasLabel } from "../../hooks/useHasLabel"; import { useLazyLoading } from "../../hooks/useLazyLoading"; import { ComboboxWrapper } from "../ComboboxWrapper"; import { InputPlaceholder } from "../Placeholder"; @@ -65,7 +64,9 @@ export function MultiSelection({ readOnly: selector.options.filterType === "none", "aria-required": ariaRequired.value }); - const hasLabel = useHasLabel(inputProps.id); + + const inputLabel = getInputLabel(inputProps.id); + const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); const memoizedselectedCaptions = useMemo( () => getSelectedCaptionsPlaceholder(selector, selectedItems), diff --git a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx index 322024c5b8..6bb58b6187 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx @@ -2,8 +2,8 @@ import classNames from "classnames"; import { Fragment, ReactElement, createElement, useMemo, useRef } from "react"; import { ClearButton } from "../../assets/icons"; import { SelectionBaseProps, SingleSelector } from "../../helpers/types"; +import { getInputLabel } from "../../helpers/utils"; import { useDownshiftSingleSelectProps } from "../../hooks/useDownshiftSingleSelectProps"; -import { useHasLabel } from "../../hooks/useHasLabel"; import { useLazyLoading } from "../../hooks/useLazyLoading"; import { ComboboxWrapper } from "../ComboboxWrapper"; import { InputPlaceholder } from "../Placeholder"; @@ -65,7 +65,9 @@ export function SingleSelection({ }, { suppressRefError: true } ); - const hasLabel = useHasLabel(inputProps.id); + + const inputLabel = getInputLabel(inputProps.id); + const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); return ( diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/utils.ts b/packages/pluggableWidgets/combobox-web/src/helpers/utils.ts index 4789e9f8ce..96fd5d8983 100644 --- a/packages/pluggableWidgets/combobox-web/src/helpers/utils.ts +++ b/packages/pluggableWidgets/combobox-web/src/helpers/utils.ts @@ -149,3 +149,7 @@ function sortSelections( } return newValueIds; } + +export function getInputLabel(inputId: string): Element | null { + return document.querySelector(`label[for="${inputId}"]`); +} diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts b/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts deleted file mode 100644 index e279364885..0000000000 --- a/packages/pluggableWidgets/combobox-web/src/hooks/useHasLabel.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useState } from "react"; - -export function useHasLabel(inputId: string): boolean { - const [hasLabel, setHasLabel] = useState(false); - - useEffect(() => { - const label = document.querySelector(`label[for="${inputId}"]`); - - setHasLabel(!!label); - }, [inputId]); - - return hasLabel; -} From 61048098d8f862c18c20e6eeb689281f96599c93 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 3 Jul 2025 15:31:27 +0200 Subject: [PATCH 5/8] feat(combobox-web): add ariaLabel property for improved accessibility --- packages/pluggableWidgets/combobox-web/src/Combobox.xml | 8 ++++++++ .../combobox-web/typings/ComboboxProps.d.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/packages/pluggableWidgets/combobox-web/src/Combobox.xml b/packages/pluggableWidgets/combobox-web/src/Combobox.xml index e35652eaf1..5f87880ab5 100644 --- a/packages/pluggableWidgets/combobox-web/src/Combobox.xml +++ b/packages/pluggableWidgets/combobox-web/src/Combobox.xml @@ -339,6 +339,14 @@ + + Aria label + Used to describe the combo box. + + Combo box + Keuzelijst + + Clear selection button Used to clear all selected values. diff --git a/packages/pluggableWidgets/combobox-web/typings/ComboboxProps.d.ts b/packages/pluggableWidgets/combobox-web/typings/ComboboxProps.d.ts index 72e23b0ab5..d23d3a96ac 100644 --- a/packages/pluggableWidgets/combobox-web/typings/ComboboxProps.d.ts +++ b/packages/pluggableWidgets/combobox-web/typings/ComboboxProps.d.ts @@ -90,6 +90,7 @@ export interface ComboboxContainerProps { onEnterEvent?: ActionValue; onLeaveEvent?: ActionValue; ariaRequired: DynamicValue; + ariaLabel?: DynamicValue; clearButtonAriaLabel?: DynamicValue; removeValueAriaLabel?: DynamicValue; a11ySelectedValue?: DynamicValue; @@ -145,6 +146,7 @@ export interface ComboboxPreviewProps { onEnterEvent: {} | null; onLeaveEvent: {} | null; ariaRequired: string; + ariaLabel: string; clearButtonAriaLabel: string; removeValueAriaLabel: string; a11ySelectedValue: string; From dac8743dcba8b1bb8a9b719419f7d466a995c65d Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 3 Jul 2025 15:35:11 +0200 Subject: [PATCH 6/8] feat(combobox-web): add ariaLabel support for MultiSelection, and SingleSelection --- packages/pluggableWidgets/combobox-web/src/Combobox.tsx | 1 + .../src/components/MultiSelection/MultiSelection.tsx | 8 ++++---- .../src/components/SingleSelection/SingleSelection.tsx | 9 +++++---- .../pluggableWidgets/combobox-web/src/helpers/types.ts | 1 + 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/src/Combobox.tsx b/packages/pluggableWidgets/combobox-web/src/Combobox.tsx index 04c0e56e71..7c4d4da91c 100644 --- a/packages/pluggableWidgets/combobox-web/src/Combobox.tsx +++ b/packages/pluggableWidgets/combobox-web/src/Combobox.tsx @@ -25,6 +25,7 @@ export default function Combobox(props: ComboboxContainerProps): ReactElement { noOptionsText: props.noOptionsText?.value, readOnlyStyle: props.readOnlyStyle, ariaRequired: props.ariaRequired, + ariaLabel: props.ariaLabel?.value, a11yConfig: { ariaLabels: { clearSelection: props.clearButtonAriaLabel?.value ?? "", diff --git a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx index 2399949ceb..7e586ba40d 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx @@ -37,6 +37,8 @@ export function MultiSelection({ const inputRef = useRef(null); const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes"; const isOptionsSelected = selector.isOptionsSelected(); + const inputLabel = getInputLabel(options.inputId); + const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); const inputProps = getInputProps({ ...getDropdownProps( { @@ -62,12 +64,10 @@ export function MultiSelection({ }, disabled: selector.readOnly, readOnly: selector.options.filterType === "none", - "aria-required": ariaRequired.value + "aria-required": ariaRequired.value, + "aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined }); - const inputLabel = getInputLabel(inputProps.id); - const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); - const memoizedselectedCaptions = useMemo( () => getSelectedCaptionsPlaceholder(selector, selectedItems), [selector, selectedItems] diff --git a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx index 6bb58b6187..eccabce5b2 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx @@ -56,19 +56,20 @@ export function SingleSelection({ ] ); + const inputLabel = getInputLabel(options.inputId); + const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); + const inputProps = getInputProps( { disabled: selector.readOnly, readOnly: selector.options.filterType === "none", ref: inputRef, - "aria-required": ariaRequired.value + "aria-required": ariaRequired.value, + "aria-label": !hasLabel && options.ariaLabel ? options.ariaLabel : undefined }, { suppressRefError: true } ); - const inputLabel = getInputLabel(inputProps.id); - const hasLabel = useMemo(() => Boolean(inputLabel), [inputLabel]); - return ( { menuFooterContent?: ReactNode; tabIndex: number; ariaRequired: DynamicValue; + ariaLabel?: string; a11yConfig: { ariaLabels: { clearSelection: string; From 9183ffb9181c2d12318e03c7c00d6df8df089ba6 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 4 Jul 2025 14:59:23 +0200 Subject: [PATCH 7/8] chore(combobox-web): update changelog --- packages/pluggableWidgets/combobox-web/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/pluggableWidgets/combobox-web/CHANGELOG.md b/packages/pluggableWidgets/combobox-web/CHANGELOG.md index 3ebf5c60ba..4d86acd76d 100644 --- a/packages/pluggableWidgets/combobox-web/CHANGELOG.md +++ b/packages/pluggableWidgets/combobox-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where combobox would show aria-labelledby even when no label was added. + +- We added the option to fill an aria-label for the combobox. + ## [2.4.2] - 2025-06-10 ### Fixed From aa483f4cdc11483ba0a3f98d0b207d55ac66fbb6 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 4 Jul 2025 14:59:42 +0200 Subject: [PATCH 8/8] chore(combobox-web): update patch verion --- packages/pluggableWidgets/combobox-web/package.json | 2 +- packages/pluggableWidgets/combobox-web/src/package.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/package.json b/packages/pluggableWidgets/combobox-web/package.json index ecedc64dc5..f0e229cd9d 100644 --- a/packages/pluggableWidgets/combobox-web/package.json +++ b/packages/pluggableWidgets/combobox-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/combobox-web", "widgetName": "Combobox", - "version": "2.4.2", + "version": "2.4.3", "description": "Configurable Combo box widget with suggestions and autocomplete.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/combobox-web/src/package.xml b/packages/pluggableWidgets/combobox-web/src/package.xml index 414286d64e..5fcc7377bf 100644 --- a/packages/pluggableWidgets/combobox-web/src/package.xml +++ b/packages/pluggableWidgets/combobox-web/src/package.xml @@ -1,6 +1,6 @@ - +