From bd5c3c3c7fbb2ad8031c83a4249f299a8419015b Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 5 Oct 2026 08:44:15 +0200 Subject: [PATCH 1/9] fix(shared): tie catalog input errors to their controls for assistive technologies Co-Authored-By: Claude Sonnet 5.5 --- .../catalog-variable-input.spec.tsx | 37 +++++++++++++++++++ .../catalog-variable-input.tsx | 32 ++++++++++++++-- .../input-select-small/input-select-small.tsx | 6 +++ .../inputs/input-toggle/input-toggle.tsx | 6 +++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx index 00ca9107a8a..bc8c41de384 100644 --- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx @@ -102,6 +102,43 @@ describe('CatalogVariableInput', () => { expect(screen.getByTestId('input-toggle').parentElement).toHaveClass('items-end') }) + it.each([ + ['a toggle in the row layout', { type: 'bool' }, 'row', 'switch', undefined], + ['a select in the row layout', { type: 'string', allowedValues: ['small', 'large'] }, 'row', 'combobox', undefined], + ['a toggle in the card layout', { type: 'bool' }, 'card', 'switch', undefined], + ['a checkbox in the card layout', { type: 'bool' }, 'card', 'checkbox', 'checkbox'], + ] as const)('ties the error to %s', (_, fieldType, layout, role, booleanControl) => { + renderWithProviders( + + ) + + const control = screen.getByRole(role, { name: 'Profile' }) + expect(control).toHaveAttribute('aria-invalid', 'true') + expect(control).toHaveAccessibleDescription('This value is not allowed.') + }) + + it('does not flag a control without error', () => { + renderWithProviders( + + ) + + const control = screen.getByRole('switch', { name: 'High availability' }) + expect(control).not.toHaveAttribute('aria-invalid') + expect(control).not.toHaveAttribute('aria-describedby') + }) + it('renders sensitive values as passwords', () => { renderWithProviders( `${field.key}-error` + function CatalogVariableControl({ autoFocus, booleanControl = 'toggle', @@ -53,6 +55,11 @@ function CatalogVariableControl({ onChange, value, }: Omit) { + const errorProps = { + 'aria-describedby': error ? getErrorId(field) : undefined, + 'aria-invalid': error ? true : undefined, + } + if (field.type === 'bool') { if (booleanControl === 'checkbox') { return ( @@ -60,6 +67,7 @@ function CatalogVariableControl({ name={field.key} id={field.key} aria-label={field.label} + {...errorProps} autoFocus={autoFocus} checked={typeof value === 'boolean' ? value : false} onCheckedChange={(checked) => { @@ -75,6 +83,8 @@ function CatalogVariableControl({ small value={typeof value === 'boolean' ? value : false} ariaLabel={field.label} + ariaDescribedBy={errorProps['aria-describedby']} + ariaInvalid={errorProps['aria-invalid']} autoFocus={autoFocus} onChange={onChange} /> @@ -86,6 +96,8 @@ function CatalogVariableControl({ ({ label: allowedValue, value: allowedValue }))} @@ -141,7 +153,9 @@ function CatalogVariableInputRow({ value={value} /> {error && (field.type === 'bool' || field.allowedValues?.length) ? ( -

{error}

+

+ {error} +

) : null} @@ -186,10 +200,16 @@ export function CatalogVariableInput({ title={field.label} description={field.description} ariaLabel={field.label} + ariaDescribedBy={error ? getErrorId(field) : undefined} + ariaInvalid={error ? true : undefined} autoFocus={autoFocus} onChange={onChange} /> - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} ) } @@ -200,6 +220,8 @@ export function CatalogVariableInput({ { @@ -214,7 +236,11 @@ export function CatalogVariableInput({ {field.description ? (

{field.description}

) : null} - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} ) } diff --git a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx index 7c60cab0551..ff06feaf1a4 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx @@ -7,6 +7,8 @@ export interface InputSelectSmallProps { name: string label?: string ariaLabel?: string + ariaDescribedBy?: string + ariaInvalid?: boolean items: Value[] getValue?: (name: string, value: Value | null) => void className?: string @@ -22,6 +24,8 @@ export function InputSelectSmall(props: InputSelectSmallProps) { name, label, ariaLabel, + ariaDescribedBy, + ariaInvalid, items, defaultValue, className = '', @@ -57,6 +61,8 @@ export function InputSelectSmall(props: InputSelectSmallProps) { data-testid={dataTestId || 'input-select-small'} name={name} aria-label={ariaLabel ?? label} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} value={value} disabled={disabled} className={twMerge( diff --git a/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx b/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx index a6c5e7987ac..278c0b944a1 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx @@ -11,6 +11,8 @@ export interface InputToggleProps { className?: string dataTestId?: string ariaLabel?: string + ariaDescribedBy?: string + ariaInvalid?: boolean align?: 'center' | 'top' disabled?: boolean name?: string @@ -29,6 +31,8 @@ export function InputToggle(props: InputToggleProps) { disabled = false, name, ariaLabel, + ariaDescribedBy, + ariaInvalid, autoFocus, } = props @@ -75,6 +79,8 @@ export function InputToggle(props: InputToggleProps) { type="button" data-testid={props.dataTestId || 'input-toggle-button'} aria-label={accessibleName} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} checked={isChecked} disabled={disabled} name={name} From 280109d1c991b06cc125f49065dfb842fd37a783 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 5 Oct 2026 08:44:46 +0200 Subject: [PATCH 2/9] fix(shared): reset the allowed-values select when its value is cleared Co-Authored-By: Claude Sonnet 5.5 --- .../catalog-variable-input.spec.tsx | 22 +++++++++++++++++++ .../catalog-variable-input.tsx | 2 ++ 2 files changed, 24 insertions(+) diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx index bc8c41de384..95e60866a5b 100644 --- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { CatalogVariableInput, type CatalogVariableInputProps } from './catalog-variable-input' @@ -124,6 +125,27 @@ describe('CatalogVariableInput', () => { expect(control).toHaveAccessibleDescription('This value is not allowed.') }) + it('does not keep a stale option once the value is cleared', async () => { + const field = { key: 'profile', label: 'Profile', type: 'string', allowedValues: ['small', 'large'] } as const + function ClearableSelect() { + const [value, setValue] = useState('large') + return ( + <> + + + + ) + } + const { userEvent } = renderWithProviders() + expect(screen.getByRole('combobox', { name: 'Profile' })).toHaveValue('large') + + await userEvent.click(screen.getByRole('button', { name: 'Clear' })) + + expect(screen.getByRole('combobox', { name: 'Profile' })).not.toHaveValue('large') + }) + it('does not flag a control without error', () => { renderWithProviders( Date: Mon, 5 Oct 2026 08:45:35 +0200 Subject: [PATCH 3/9] fix(shared): give the modal close button an accessible name Co-Authored-By: Claude Sonnet 5.5 --- .../clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx | 3 ++- libs/shared/ui/src/lib/components/modal/modal.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx index 2817251cffb..69c3009875b 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx @@ -76,6 +76,7 @@ describe('ClusterAdd', () => { selectedCloudProvider: 'AWS', selectedInstallationType: 'self-managed', }) + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() await userEvent.keyboard('{Escape}') await waitFor(() => expect(screen.queryByText('Creation flow')).not.toBeInTheDocument()) @@ -89,7 +90,7 @@ describe('ClusterAdd', () => { await userEvent.keyboard('{Escape}') expect(screen.getByText('Creation flow')).toBeInTheDocument() - expect(document.querySelector('.fa-xmark')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Operator connected' })) diff --git a/libs/shared/ui/src/lib/components/modal/modal.tsx b/libs/shared/ui/src/lib/components/modal/modal.tsx index 6248b8ea07a..35d08a067c0 100644 --- a/libs/shared/ui/src/lib/components/modal/modal.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal.tsx @@ -181,6 +181,7 @@ export const Modal = (props: ModalProps) => {