dbeaver/pro#5241 [CB] Screen reader does not read some fields in the application (#3585)

* dbeaver/pro#5241 fix: input doesn't create label wrapper if label is not provided

* dbeaver/pro#5241 fix: link label to input inside InputFieldBase

* dbeaver/pro#5241 fix: make required field asterisk invisible for assistive technologies

* dbeaver/pro#5241 fix: link FieldLabel to textarea with generated id and add aria-label for accessibility

even with linked label, voiceOver doesn't pronounce label when focusing textarea, that's why aria-label is used there

* dbeaver/pro#5241 refactor: force aria-label for input field

* dbeaver/pro#5241 refactor: change role for snackbar

aria-live polite works with aria regions which initially empty and then dynamically populated with some data. If we want assistive technologies read initial content, we need to use alert role.

also hide icon from assistive technologies (no additional information from that)

* dbeaver/pro#5241 refactor: remove aria-label from input

use native htmlFor

* dbeaver/pro#5241 refactor: remove aria-label from Textarea component

---------

Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
This commit is contained in:
Sychev Andrey
2025-07-29 23:09:05 +08:00
committed by GitHub
co-authored by Evgenia mr-anton-t
parent ad6973d069
commit 603b94c551
7 changed files with 65 additions and 20 deletions
@@ -6,23 +6,49 @@
* you may not use this file except in compliance with the License.
*/
import { type ComponentPropsWithRef } from 'react';
import { type ComponentPropsWithRef, type ReactElement, useId } from 'react';
import './Input.css';
import clsx from 'clsx';
import { componentProviderWrapper } from '../componentProviderWrapper.js';
export interface InputProps extends Omit<ComponentPropsWithRef<'input'>, 'size'> {
size?: 'small' | 'medium' | 'large' | 'xlarge';
label?: React.ReactNode;
}
export type InputProps = Omit<ComponentPropsWithRef<'input'>, 'size'> & {
label?: ReactElement | string;
size?: 'small' | 'medium' | 'large' | 'xlarge';
};
export const InputBase: React.FC<InputProps> = function Input({ size, className, ...props }) {
export const InputBase: React.FC<InputProps> = function Input({
size,
className,
label,
'aria-labelledby': ariaLabelledBy,
'aria-label': ariaLabel,
...props
}) {
const labelId = useId();
const classNameToApply = clsx(`dbv-kit-input`, `dbv-kit-input--${size ?? 'medium'}`, className);
if (label) {
return (
<label className="dbv-kit-input-wrapper">
<div id={labelId} className={clsx('dbv-kit-input__title', props.required && 'dbv-kit-input__title--required')}>
{label}
</div>
<input
className={classNameToApply}
aria-labelledby={labelId}
{...props}
/>
</label>
);
}
return (
<label className="dbv-kit-input-wrapper">
{props.label && <div className={clsx('dbv-kit-input__title', props.required && 'dbv-kit-input__title--required')}>{props.label}</div>}
<input className={classNameToApply} {...props} />
</label>
<input
className={classNameToApply}
aria-labelledby={ariaLabelledBy}
aria-label={ariaLabel}
{...props}
/>
);
};
@@ -12,3 +12,7 @@
white-space: nowrap;
text-overflow: ellipsis;
}
.required {
color: var(--theme-negative);
}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -23,7 +23,12 @@ export const FieldLabel: React.FC<PropsWithChildren<Props>> = observer(function
return (
<label {...rest} className={s(styles, { fieldLabel: true }, className)}>
{children}
{required && ' *'}
{required && (
<span className={s(styles, { required })} aria-hidden>
{' '}
*
</span>
)}
</label>
);
});
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import React, { forwardRef, useCallback, useLayoutEffect, useRef, useState } from 'react';
import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
import { isNotNullDefined } from '@dbeaver/js-helpers';
@@ -62,6 +62,7 @@ export const InputFieldBase = observer<InputFieldBaseProps, HTMLInputElement>(
ref,
) {
const [uncontrolledInputValue, setUncontrolledInputValue] = useState(value);
const inputId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const mergedRef = useCombinedRef(inputRef, ref);
const capsLock = useCapsLockTracker();
@@ -116,13 +117,14 @@ export const InputFieldBase = observer<InputFieldBaseProps, HTMLInputElement>(
return (
<Field {...layoutProps} className={s(styles, {}, className)}>
<FieldLabel title={labelTooltip || rest.title} className={s(styles, { fieldLabel: true })} required={required}>
<FieldLabel htmlFor={inputId} title={labelTooltip || rest.title} className={s(styles, { fieldLabel: true })} required={required}>
{children}
</FieldLabel>
<div className={s(styles, { inputContainer: true })}>
<Input
ref={mergedRef}
{...rest}
id={inputId}
type={passwordRevealed ? 'text' : rest.type}
name={name}
value={uncontrolled ? undefined : value}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useContext, useLayoutEffect, useRef } from 'react';
import { useCallback, useContext, useId, useLayoutEffect, useRef } from 'react';
import { getTextFileReadingProcess } from '@cloudbeaver/core-utils';
@@ -69,6 +69,7 @@ export const Textarea: TextareaType = observer(function Textarea({
...rest
}: ControlledProps | ObjectProps<any, any>) {
const translate = useTranslate();
const inputId = useId();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const layoutProps = getLayoutProps(rest);
rest = filterLayoutFakeProps(rest);
@@ -102,12 +103,13 @@ export const Textarea: TextareaType = observer(function Textarea({
return (
<Field {...layoutProps} className={s(styles, { field: true, embedded }, className)}>
<FieldLabel className={s(styles, { fieldLabel: true })} title={labelTooltip || rest.title} required={required}>
<FieldLabel htmlFor={inputId} className={s(styles, { fieldLabel: true })} title={labelTooltip || rest.title} required={required}>
{children}
</FieldLabel>
<textarea
{...rest}
ref={textareaRef}
id={inputId}
required={required}
className={s(styles, { textarea: true })}
value={value ?? ''}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@ type IconProps = HTMLProps<HTMLDivElement> & {
export const NotificationMark: React.FC<IconProps> = function NotificationMark({ type, ...props }) {
return (
<div {...props}>
<div aria-hidden {...props}>
{type === ENotificationType.Info && <IconOrImage icon="/icons/info_icon.svg" />}
{type === ENotificationType.Error && <IconOrImage icon="/icons/error_icon.svg" />}
{type === ENotificationType.Success && <IconOrImage icon="/icons/success_icon.svg" />}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -36,7 +36,13 @@ export const SnackbarWrapper: React.FC<React.PropsWithChildren<Props>> = functio
}, []);
return (
<div tabIndex={0} role="status" aria-live="polite" aria-atomic="true" className={s(styles, { notification: true, mounted, closing }, className)}>
<div
tabIndex={0}
role="alert"
aria-live="assertive"
aria-atomic="true"
className={s(styles, { notification: true, mounted, closing }, className)}
>
{children}
{!persistent && onClose && (
<div className={s(styles, { iconButton: true })}>