]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22325 Fix a11y issues on project setting page (#11737)
authorSarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com>
Tue, 17 Sep 2024 07:30:09 +0000 (09:30 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 17 Sep 2024 20:02:39 +0000 (20:02 +0000)
28 files changed:
server/sonar-web/design-system/src/components/Switch.tsx
server/sonar-web/design-system/src/components/Text.tsx
server/sonar-web/design-system/src/components/subnavigation/SubnavigationGroup.tsx
server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx
server/sonar-web/src/main/js/apps/settings/components/AnalysisScope.tsx
server/sonar-web/src/main/js/apps/settings/components/Definition.tsx
server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx
server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx
server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx
server/sonar-web/src/main/js/apps/settings/components/__tests__/Definition-it.tsx
server/sonar-web/src/main/js/apps/settings/components/__tests__/Languages-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/Input.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForBoolean.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForJSON.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForNumber.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForPassword.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSecured.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSingleSelectList.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForString.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForText.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/MultiValueInput.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/PrimitiveInput.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/PropertySetInput.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/SimpleInput.tsx
server/sonar-web/src/main/js/apps/settings/utils.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index e75e76fd32591cc3c4a3b91d91fffcd2ea630fc2..fb0aa8a389ae8d917385d28be69ab378b37f6d71 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
+import { ForwardedRef, forwardRef } from 'react';
 import tw from 'twin.macro';
 import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers';
 import { CheckIcon } from './icons';
@@ -37,7 +38,7 @@ const getValue = (value: boolean | string) => {
   return typeof value === 'string' ? value === 'true' : value;
 };
 
-export function Switch(props: Readonly<Props>) {
+function SwitchWithRef(props: Readonly<Props>, ref: ForwardedRef<HTMLButtonElement>) {
   const { disabled, onChange, name, labels } = props;
   const value = getValue(props.value);
 
@@ -56,6 +57,7 @@ export function Switch(props: Readonly<Props>) {
       disabled={disabled}
       name={name}
       onClick={handleClick}
+      ref={ref}
       role="switch"
       type="button"
     >
@@ -118,3 +120,5 @@ const StyledSwitch = styled.button<StyledProps>`
       active ? themeBorder('focus', 'switchActive') : themeBorder('focus', 'switch')};
   }
 `;
+
+export const Switch = forwardRef(SwitchWithRef);
index 1170d99bf56a687e68d84aed9dbbbe9f8ef5f879..da407eed159e1b1232b0a3581196092f002ce19f 100644 (file)
@@ -68,20 +68,26 @@ export function PageTitle({
 }
 
 export function TextError({
+  as,
   text,
   className,
 }: Readonly<{
+  as?: React.ElementType;
   className?: string;
   text: string | React.ReactNode;
 }>) {
   if (typeof text === 'string') {
     return (
-      <StyledTextError className={className} title={text}>
+      <StyledTextError as={as} className={className} title={text}>
         {text}
       </StyledTextError>
     );
   }
-  return <StyledTextError className={className}>{text}</StyledTextError>;
+  return (
+    <StyledTextError as={as} className={className}>
+      {text}
+    </StyledTextError>
+  );
 }
 
 export function TextSuccess({ text, className }: Readonly<{ className?: string; text: string }>) {
index 0b2fb5523026533c0855b7559c7edfa30a1e1041..797eb87d0fc23c1b25ac83c958778cc646c5c170 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
-import { Children, Fragment, HtmlHTMLAttributes, ReactNode } from 'react';
+import { Children, ElementType, Fragment, HtmlHTMLAttributes, ReactNode } from 'react';
 import tw from 'twin.macro';
 import { themeBorder, themeColor } from '../../helpers/theme';
 import { isDefined } from '../../helpers/types';
 
 interface Props extends HtmlHTMLAttributes<HTMLDivElement> {
+  as?: ElementType;
   children: ReactNode;
   className?: string;
 }
 
-export function SubnavigationGroup({ className, children, ...htmlProps }: Props) {
+export function SubnavigationGroup({ as, className, children, ...htmlProps }: Readonly<Props>) {
   const childrenArray = Children.toArray(children).filter(isDefined);
   return (
-    <Group className={className} {...htmlProps}>
+    <Group as={as} className={className} {...htmlProps}>
       {childrenArray.map((child, index) => (
         <Fragment key={index}>
           {child}
index ad4cd62e690b2837a11a954fc4b8f4667600f9cc..158c5a9c7eb2a0b1efb6880286f544db6e949504 100644 (file)
@@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom';
 import withAvailableFeatures, {
   WithAvailableFeaturesProps,
 } from '../../../app/components/available-features/withAvailableFeatures';
+import { translate } from '../../../helpers/l10n';
 import { getGlobalSettingsUrl, getProjectSettingsUrl } from '../../../helpers/urls';
 import { Feature } from '../../../types/features';
 import { Component } from '../../../types/types';
@@ -78,12 +79,18 @@ function CategoriesList(props: Readonly<CategoriesListProps>) {
   const sortedCategories = sortBy(categoriesWithName, (category) => category.name.toLowerCase());
 
   return (
-    <SubnavigationGroup className="sw-box-border it__subnavigation_menu">
+    <SubnavigationGroup
+      as="nav"
+      aria-label={translate('settings.page')}
+      className="sw-box-border it__subnavigation_menu"
+    >
       {sortedCategories.map((c) => {
         const category = c.key !== defaultCategory ? c.key.toLowerCase() : undefined;
+        const isActive = c.key.toLowerCase() === selectedCategory.toLowerCase();
         return (
           <SubnavigationItem
-            active={c.key.toLowerCase() === selectedCategory.toLowerCase()}
+            active={isActive}
+            ariaCurrent={isActive}
             onClick={() => openCategory(category)}
             key={c.key}
           >
index f60b393aa826c679046c5a65f241a20186c70b48..1e47c8781f4846659657ec5f0cf04e56783f1d8a 100644 (file)
@@ -48,7 +48,7 @@ export function AnalysisScope(props: AdditionalCategoryComponentProps) {
 
         <div className="sw-col-span-2">
           <DocumentationLink to={DocLink.AnalysisScope}>
-            {translate('learn_more')}
+            {translate('settings.analysis_scope.learn_more')}
           </DocumentationLink>
         </div>
       </StyledGrid>
index ddb94afff83e9ae53ca17bc783ea67ca2336b1f0..2037998ad75d1e2f709364ae49c92428dbd1088a 100644 (file)
@@ -33,6 +33,7 @@ import { Component } from '../../../types/types';
 import {
   combineDefinitionAndSettingValue,
   getSettingValue,
+  getUniqueName,
   isDefaultOrInherited,
   isEmptyValue,
   isURLKind,
@@ -59,6 +60,8 @@ export default function Definition(props: Readonly<Props>) {
   const [success, setSuccess] = React.useState(false);
   const [changedValue, setChangedValue] = React.useState<FieldValue>();
   const [validationMessage, setValidationMessage] = React.useState<string>();
+  const ref = React.useRef<HTMLElement>(null);
+  const name = getUniqueName(definition);
 
   const { data: loadedSettingValue, isLoading } = useGetValueQuery({
     key: definition.key,
@@ -67,6 +70,7 @@ export default function Definition(props: Readonly<Props>) {
 
   // WARNING: do *not* remove `?? undefined` below, it is required to change `null` to `undefined`!
   // (Yes, it's ugly, we really shouldn't use `null` as the fallback value in useGetValueQuery)
+  // prettier-ignore
   const settingValue = isLoading ? initialSettingValue : (loadedSettingValue ?? undefined);
 
   const { mutateAsync: resetSettingValue } = useResetSettingsMutation();
@@ -92,6 +96,7 @@ export default function Definition(props: Readonly<Props>) {
       setChangedValue(undefined);
       setLoading(false);
       setSuccess(true);
+      ref.current?.focus();
       setValidationMessage(undefined);
 
       timeout.current = window.setTimeout(() => {
@@ -101,6 +106,7 @@ export default function Definition(props: Readonly<Props>) {
       const validationMessage = await parseError(e as Response);
       setLoading(false);
       setValidationMessage(validationMessage);
+      ref.current?.focus();
     }
   };
 
@@ -117,6 +123,7 @@ export default function Definition(props: Readonly<Props>) {
       } else {
         setValidationMessage(translate('settings.state.value_cant_be_empty'));
       }
+      ref.current?.focus();
 
       return false;
     }
@@ -129,6 +136,7 @@ export default function Definition(props: Readonly<Props>) {
         setValidationMessage(
           translateWithParameters('settings.state.url_not_valid', value?.toString() ?? ''),
         );
+        ref.current?.focus();
 
         return false;
       }
@@ -139,6 +147,7 @@ export default function Definition(props: Readonly<Props>) {
         JSON.parse(value?.toString() ?? '');
       } catch (e) {
         setValidationMessage((e as Error).message);
+        ref.current?.focus();
 
         return false;
       }
@@ -154,6 +163,7 @@ export default function Definition(props: Readonly<Props>) {
 
       if (isEmptyValue(definition, changedValue)) {
         setValidationMessage(translate('settings.state.value_cant_be_empty'));
+        ref.current?.focus();
 
         return;
       }
@@ -167,6 +177,7 @@ export default function Definition(props: Readonly<Props>) {
         setIsEditing(false);
         setLoading(false);
         setSuccess(true);
+        ref.current?.focus();
 
         timeout.current = window.setTimeout(() => {
           setSuccess(false);
@@ -175,6 +186,7 @@ export default function Definition(props: Readonly<Props>) {
         const validationMessage = await parseError(e as Response);
         setLoading(false);
         setValidationMessage(validationMessage);
+        ref.current?.focus();
       }
     }
   };
@@ -189,15 +201,16 @@ export default function Definition(props: Readonly<Props>) {
   return (
     <div data-key={definition.key} data-testid={definition.key} className="sw-flex sw-gap-12">
       <DefinitionDescription definition={definition} />
-
       <div className="sw-flex-1">
         <form onSubmit={formNoop}>
           <Input
+            ariaDescribedBy={`definition-stats-${name}`}
             hasValueChanged={hasValueChanged}
             onCancel={handleCancel}
             onChange={handleChange}
             onSave={handleSave}
             onEditing={() => setIsEditing(true)}
+            ref={ref}
             isEditing={isEditing}
             isInvalid={hasError}
             setting={settingDefinitionAndValue}
@@ -206,16 +219,18 @@ export default function Definition(props: Readonly<Props>) {
 
           <div className="sw-mt-2">
             {loading && (
-              <div className="sw-flex">
-                <Spinner />
+              <div id={`definition-stats-${name}`} className="sw-flex">
+                <Spinner aria-busy />
 
                 <Note className="sw-ml-2">{translate('settings.state.saving')}</Note>
               </div>
             )}
 
             {!loading && validationMessage && (
-              <div>
+              <div id={`definition-stats-${name}`}>
                 <TextError
+                  as="output"
+                  className="sw-whitespace-break-spaces"
                   text={translateWithParameters(
                     'settings.state.validation_failed',
                     validationMessage,
@@ -225,12 +240,15 @@ export default function Definition(props: Readonly<Props>) {
             )}
 
             {!loading && !hasError && success && (
-              <FlagMessage variant="success">{translate('settings.state.saved')}</FlagMessage>
+              <FlagMessage id={`definition-stats-${name}`} variant="success">
+                {translate('settings.state.saved')}
+              </FlagMessage>
             )}
           </div>
 
           <DefinitionActions
             changedValue={changedValue}
+            definition={definition}
             hasError={hasError}
             hasValueChanged={hasValueChanged}
             isDefault={isDefault}
index a505333cf00b5c666953843cd0439b59c378a91f..0c81bb8f73ffff6361bcbf46c71502373389ae0f 100644 (file)
@@ -21,11 +21,12 @@ import { Button, ButtonGroup, ButtonVariety } from '@sonarsource/echoes-react';
 import { Modal, Note } from 'design-system';
 import * as React from 'react';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { Setting } from '../../../types/settings';
+import { ExtendedSettingDefinition, Setting } from '../../../types/settings';
 import { getDefaultValue, getPropertyName, isEmptyValue } from '../utils';
 
 type Props = {
   changedValue?: string | string[] | boolean;
+  definition: ExtendedSettingDefinition;
   hasError: boolean;
   hasValueChanged: boolean;
   isDefault: boolean;
@@ -79,17 +80,22 @@ export default class DefinitionActions extends React.PureComponent<Props, State>
   }
 
   render() {
-    const { setting, changedValue, isDefault, isEditing, hasValueChanged, hasError } = this.props;
+    const { definition, setting, changedValue, isDefault, isEditing, hasValueChanged, hasError } =
+      this.props;
     const hasBeenChangedToEmptyValue =
       changedValue !== undefined && isEmptyValue(setting.definition, changedValue);
     const showReset = hasBeenChangedToEmptyValue || (!isDefault && setting.hasValue);
     const showCancel = hasValueChanged || isEditing;
+    const propertyName = getPropertyName(definition);
+    const saveButtonLabel = `${translate('save')} ${propertyName}`;
+    const cancelButtonLabel = `${translate('cancel')} ${propertyName}`;
 
     return (
       <div className="sw-mt-8">
         <ButtonGroup className="sw-mr-3">
           {hasValueChanged && (
             <Button
+              aria-label={saveButtonLabel}
               isDisabled={hasError}
               onClick={this.props.onSave}
               variety={ButtonVariety.Primary}
@@ -110,7 +116,11 @@ export default class DefinitionActions extends React.PureComponent<Props, State>
             </Button>
           )}
 
-          {showCancel && <Button onClick={this.props.onCancel}>{translate('cancel')}</Button>}
+          {showCancel && (
+            <Button aria-label={cancelButtonLabel} onClick={this.props.onCancel}>
+              {translate('cancel')}
+            </Button>
+          )}
         </ButtonGroup>
 
         {showReset && (
index 342f00dde2b143414326e1908b3412de0af0d3b6..69cf100f557497ccb08be30caa59e98a1331fb66 100644 (file)
@@ -35,7 +35,9 @@ export default function DefinitionDescription({ definition }: Readonly<Props>) {
 
   return (
     <div className="sw-w-abs-300">
-      <SubHeading title={propertyName}>{propertyName}</SubHeading>
+      <SubHeading className="sw-text-ellipsis sw-overflow-hidden" title={propertyName}>
+        {propertyName}
+      </SubHeading>
 
       {description && (
         <div
index 966415aecbc70ba9158bfae22fc612603b4cfdce..bea02959d5f37badd0f08875a980cea2e5278dfc 100644 (file)
@@ -86,7 +86,7 @@ class SubCategoryDefinitionsList extends React.PureComponent<SubCategoryDefiniti
           <li className={noPadding ? '' : 'sw-p-6'} key={subCategory.key}>
             {displaySubCategoryTitle && (
               <SubTitle
-                as="h3"
+                as="h2"
                 data-key={subCategory.key}
                 ref={this.scrollToSubCategoryOrDefinition}
               >
index d85b7e53b5d7b067310ed9f0e6d66e45aca30915..6c6c81735ee7ab9ca25532b7c00258d29f4f14bf 100644 (file)
@@ -50,7 +50,8 @@ const ui = {
   securedInput: byRole('textbox', { name: 'property.sonar.announcement.message.secured.name' }),
   multiValuesInput: byRole('textbox', { name: 'property.sonar.javascript.globals.name' }),
   urlKindInput: byRole('textbox', { name: /sonar.auth.gitlab.url/ }),
-  fieldsInput: (name: string) => byRole('textbox', { name: `property.${name}.name` }),
+  nameInput: byRole('textbox', { name: /property.name.name/ }),
+  valueInput: byRole('textbox', { name: /property.value.name/ }),
   savedMsg: byText('settings.state.saved'),
   validationMsg: byText(/settings.state.validation_failed/),
   jsonFormatStatus: byText('settings.json.format_error'),
@@ -58,8 +59,8 @@ const ui = {
   toggleButton: byRole('switch'),
   selectOption: (name: string) => byRole('option', { name }),
   selectInput: byRole('combobox', { name: 'property.test.single.select.list.name' }),
-  saveButton: byRole('button', { name: 'save' }),
-  cancelButton: byRole('button', { name: 'cancel' }),
+  saveButton: byRole('button', { name: /save/ }),
+  cancelButton: byRole('button', { name: /cancel/ }),
   changeButton: byRole('button', { name: 'change_verb' }),
   resetButton: (name: string | RegExp = 'reset_verb') => byRole('button', { name }),
   deleteValueButton: byRole('button', {
@@ -291,22 +292,22 @@ it('renders definition for SettingType = PROPERTY_SET and can do operations', as
   expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument();
 
   // Should type new values
-  await user.type(ui.fieldsInput('name').get(), 'any name');
-  expect(ui.fieldsInput('name').getAll()).toHaveLength(2);
+  await user.type(ui.nameInput.get(), 'any name');
+  expect(ui.nameInput.getAll()).toHaveLength(2);
 
   // Can cancel changes
   await user.click(ui.cancelButton.get());
-  expect(ui.fieldsInput('name').getAll()).toHaveLength(1);
-  expect(ui.fieldsInput('name').get()).toHaveValue('');
+  expect(ui.nameInput.getAll()).toHaveLength(1);
+  expect(ui.nameInput.get()).toHaveValue('');
 
   // Can save new values
-  await user.type(ui.fieldsInput('name').get(), 'any name');
-  await user.type(ui.fieldsInput('value').getAll()[0], 'any value');
+  await user.type(ui.nameInput.get(), 'any name');
+  await user.type(ui.valueInput.getAll()[0], 'any value');
   await user.click(ui.saveButton.get());
 
   expect(ui.savedMsg.get()).toBeInTheDocument();
-  expect(ui.fieldsInput('name').getAll()[0]).toHaveValue('any name');
-  expect(ui.fieldsInput('value').getAll()[0]).toHaveValue('any value');
+  expect(ui.nameInput.getAll()[0]).toHaveValue('any name');
+  expect(ui.valueInput.getAll()[0]).toHaveValue('any value');
 
   // Deleting previous value show validation message
   await user.click(ui.deleteFieldsButton.get());
@@ -317,8 +318,8 @@ it('renders definition for SettingType = PROPERTY_SET and can do operations', as
   await user.click(ui.resetButton().get());
 
   expect(ui.savedMsg.get()).toBeInTheDocument();
-  expect(ui.fieldsInput('name').get()).toHaveValue('');
-  expect(ui.fieldsInput('value').get()).toHaveValue('');
+  expect(ui.nameInput.get()).toHaveValue('');
+  expect(ui.valueInput.get()).toHaveValue('');
 });
 
 it('renders secured definition and can do operations', async () => {
index 80e02f2f0af31e6fb3189a204ca0158fcc6a60df..889dde318b028a4c01870a32c6c79a3cab66f5be 100644 (file)
@@ -53,14 +53,14 @@ const ui = {
   jsFileSuffixesHeading: byRole('heading', {
     name: 'property.sonar.javascript.file.suffixes.name',
   }),
-  jsGlobalVariablesInput: byRole('textbox', { name: 'property.sonar.javascript.globals.name' }),
+  jsGlobalVariablesInput: byRole('textbox', { name: /property.sonar.javascript.globals.name -/ }),
   jsResetGlobalVariablesButton: byRole('button', {
     name: 'settings.definition.reset.property.sonar.javascript.globals.name',
   }),
 
   validationMsg: byText('settings.state.validation_failed.A non empty value must be provided'),
-  saveButton: byRole('button', { name: 'save' }),
-  cancelButton: byRole('button', { name: 'cancel' }),
+  saveButton: byRole('button', { name: 'save property.sonar.javascript.globals.name' }),
+  cancelButton: byRole('button', { name: 'cancel property.sonar.javascript.globals.name' }),
   resetButton: byRole('button', { name: 'reset_verb' }),
 };
 
index b6cf04e6fc475b088a6d4895c2dac73295d184b9..61600662b49eab53614c1d4f2ef3d58fa42b9ad7 100644 (file)
@@ -46,8 +46,8 @@ const allowUsersToSignUpDefinition = byTestId('sonar.auth.bitbucket.allowUsersTo
 const workspacesDefinition = byTestId('sonar.auth.bitbucket.workspaces');
 
 const ui = {
-  save: byRole('button', { name: 'save' }),
-  cancel: byRole('button', { name: 'cancel' }),
+  save: byRole('button', { name: /save/ }),
+  cancel: byRole('button', { name: /cancel/ }),
   reset: byRole('button', { name: /settings.definition.reset/ }),
   confirmReset: byRole('dialog').byRole('button', { name: 'reset_verb' }),
   change: byRole('button', { name: 'change_verb' }),
index 98f7f17b7292bc159737b545baa2c380a2f487b7..4337ca52831db7661bd6f040214b2df578607eaf 100644 (file)
@@ -32,13 +32,14 @@ import MultiValueInput from './MultiValueInput';
 import PrimitiveInput from './PrimitiveInput';
 import PropertySetInput from './PropertySetInput';
 
-export default function Input(props: Readonly<DefaultInputProps>) {
+function Input(props: Readonly<DefaultInputProps>, ref: React.ForwardedRef<HTMLElement>) {
   const { setting } = props;
   const { definition } = setting;
   const name = getUniqueName(definition);
 
-  let Input: React.ComponentType<React.PropsWithChildren<DefaultSpecializedInputProps>> =
-    PrimitiveInput;
+  let Input: React.ComponentType<
+    React.PropsWithChildren<DefaultSpecializedInputProps> & React.RefAttributes<HTMLElement>
+  > = PrimitiveInput;
 
   if (isCategoryDefinition(definition) && definition.multiValues) {
     Input = MultiValueInput;
@@ -49,8 +50,10 @@ export default function Input(props: Readonly<DefaultInputProps>) {
   }
 
   if (isSecuredDefinition(definition)) {
-    return <InputForSecured input={Input} {...props} />;
+    return <InputForSecured input={Input} ref={ref} {...props} />;
   }
 
-  return <Input {...props} name={name} isDefault={isDefaultOrInherited(setting)} />;
+  return <Input {...props} name={name} ref={ref} isDefault={isDefaultOrInherited(setting)} />;
 }
+
+export default React.forwardRef(Input);
index 3dcc86492cd0f760483b7e8a186dc14644a0b6de..62f094d0b04278363ad5013c6ccbc83533abc341 100644 (file)
@@ -26,7 +26,10 @@ interface Props extends DefaultSpecializedInputProps {
   value: string | boolean | undefined;
 }
 
-export default function InputForBoolean({ onChange, name, value, setting }: Props) {
+function InputForBoolean(
+  { onChange, name, value, setting, ...other }: Readonly<Props>,
+  ref: React.ForwardedRef<HTMLButtonElement>,
+) {
   const toggleValue = getToggleValue(value != null ? value : false);
 
   const propertyName = getPropertyName(setting.definition);
@@ -36,11 +39,13 @@ export default function InputForBoolean({ onChange, name, value, setting }: Prop
       <Switch
         name={name}
         onChange={onChange}
+        ref={ref}
         value={toggleValue}
         labels={{
           on: propertyName,
           off: propertyName,
         }}
+        {...other}
       />
       {value == null && <Note className="sw-ml-2">{translate('settings.not_set')}</Note>}
     </div>
@@ -50,3 +55,5 @@ export default function InputForBoolean({ onChange, name, value, setting }: Prop
 function getToggleValue(value: string | boolean) {
   return typeof value === 'string' ? value === 'true' : value;
 }
+
+export default React.forwardRef(InputForBoolean);
index 92b1fabb5f7cbcb6ace852e259e932a55dca2537..d2372b36a5f90dd32ad2c637c415b88cb9de1a07 100644 (file)
@@ -33,7 +33,10 @@ import { translate } from '../../../../helpers/l10n';
 import { sanitizeUserInput } from '../../../../helpers/sanitize';
 import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
-export default function InputForFormattedText(props: DefaultSpecializedInputProps) {
+function InputForFormattedText(
+  props: DefaultSpecializedInputProps,
+  ref: React.ForwardedRef<HTMLTextAreaElement>,
+) {
   const { isEditing, setting, name, value } = props;
   const { values, hasValue } = setting;
   const editMode = !hasValue || isEditing;
@@ -52,6 +55,7 @@ export default function InputForFormattedText(props: DefaultSpecializedInputProp
         className="settings-large-input sw-mr-2"
         name={name}
         onChange={handleInputChange}
+        ref={ref}
         rows={5}
         value={value || ''}
       />
@@ -82,3 +86,5 @@ const FormattedPreviewBox = styled.div`
   overflow-wrap: break-word;
   line-height: 1.5;
 `;
+
+export default React.forwardRef(InputForFormattedText);
index 39ca71cd72e84f0039b2b1a11346bd27991e0bb6..ea106b989db17d1969ec61eb0ee45b1d3ccf5882 100644 (file)
@@ -26,11 +26,15 @@ import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
 const JSON_SPACE_SIZE = 4;
 
+interface Props extends DefaultSpecializedInputProps {
+  innerRef: React.ForwardedRef<HTMLTextAreaElement>;
+}
+
 interface State {
   formatError: boolean;
 }
 
-export default class InputForJSON extends React.PureComponent<DefaultSpecializedInputProps, State> {
+class InputForJSON extends React.PureComponent<Props, State> {
   state: State = { formatError: false };
 
   handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -50,7 +54,7 @@ export default class InputForJSON extends React.PureComponent<DefaultSpecialized
   };
 
   render() {
-    const { value, name, setting, isInvalid } = this.props;
+    const { value, name, innerRef, setting, isInvalid } = this.props;
     const { formatError } = this.state;
 
     return (
@@ -60,6 +64,7 @@ export default class InputForJSON extends React.PureComponent<DefaultSpecialized
             size="large"
             name={name}
             onChange={this.handleInputChange}
+            ref={innerRef}
             rows={5}
             value={value || ''}
             aria-label={getPropertyName(setting.definition)}
@@ -80,3 +85,9 @@ export default class InputForJSON extends React.PureComponent<DefaultSpecialized
     );
   }
 }
+
+export default React.forwardRef(
+  (props: DefaultSpecializedInputProps, ref: React.ForwardedRef<HTMLTextAreaElement>) => (
+    <InputForJSON innerRef={ref} {...props} />
+  ),
+);
index 20cee416a72f409a4d0e269bb286837efc3a0d3b..858a3ccb0e795781ddde7b4f1c3c58f094b0bd80 100644 (file)
@@ -21,6 +21,11 @@ import * as React from 'react';
 import { DefaultSpecializedInputProps } from '../../utils';
 import SimpleInput from './SimpleInput';
 
-export default function InputForNumber(props: DefaultSpecializedInputProps) {
-  return <SimpleInput size="small" type="text" {...props} />;
+function InputForNumber(
+  props: DefaultSpecializedInputProps,
+  ref: React.ForwardedRef<HTMLInputElement>,
+) {
+  return <SimpleInput ref={ref} size="small" type="text" {...props} />;
 }
+
+export default React.forwardRef(InputForNumber);
index 2606a4595f3e7f977b15b3ce9657a829f7b8f0b4..552a993489d5b574ae71549f8ec207a330bec1e8 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import * as React from 'react';
 import { DefaultSpecializedInputProps } from '../../utils';
 import SimpleInput from './SimpleInput';
 
-export default function InputForPassword(props: DefaultSpecializedInputProps) {
-  return <SimpleInput {...props} size="large" type="password" autoComplete="off" />;
+function InputForPassword(
+  props: DefaultSpecializedInputProps,
+  ref: React.ForwardedRef<HTMLInputElement>,
+) {
+  return <SimpleInput {...props} ref={ref} size="large" type="password" autoComplete="off" />;
 }
+
+export default React.forwardRef(InputForPassword);
index d374ff4183826636858c0695f9bbebe211d19c42..c3f55d09ff8549b2a75186230756211441b981e4 100644 (file)
@@ -34,10 +34,13 @@ interface State {
 }
 
 interface Props extends DefaultInputProps {
-  input: React.ComponentType<React.PropsWithChildren<DefaultSpecializedInputProps>>;
+  innerRef: React.ForwardedRef<HTMLElement>;
+  input: React.ComponentType<
+    React.PropsWithChildren<DefaultSpecializedInputProps> & React.RefAttributes<HTMLElement>
+  >;
 }
 
-export default class InputForSecured extends React.PureComponent<Props, State> {
+class InputForSecured extends React.PureComponent<Props, State> {
   state: State = {
     changing: !this.props.setting.hasValue,
   };
@@ -66,7 +69,7 @@ export default class InputForSecured extends React.PureComponent<Props, State> {
   };
 
   renderInput() {
-    const { input: Input, setting, value } = this.props;
+    const { input: Input, innerRef, setting, value } = this.props;
     const name = getUniqueName(setting.definition);
     return (
       // The input hidden will prevent browser asking for saving login information
@@ -76,9 +79,11 @@ export default class InputForSecured extends React.PureComponent<Props, State> {
           aria-label={getPropertyName(setting.definition)}
           autoComplete="off"
           className="js-setting-input"
+          id={`input-${name}`}
           isDefault={isDefaultOrInherited(setting)}
           name={name}
           onChange={this.handleInputChange}
+          ref={innerRef}
           setting={setting}
           size="large"
           type="password"
@@ -101,3 +106,9 @@ export default class InputForSecured extends React.PureComponent<Props, State> {
     );
   }
 }
+
+export default React.forwardRef(
+  (props: Omit<Props, 'innerRef'>, ref: React.ForwardedRef<HTMLElement>) => (
+    <InputForSecured innerRef={ref} {...props} />
+  ),
+);
index 25ab1b3e4f30c54ef3f749f7e1a47dcad136bf30..9ece399691c55ba8d94ea57b9a06619185814b48 100644 (file)
@@ -24,7 +24,10 @@ import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
 type Props = DefaultSpecializedInputProps & Pick<ExtendedSettingDefinition, 'options'>;
 
-export default function InputForSingleSelectList(props: Readonly<Props>) {
+function InputForSingleSelectList(
+  props: Readonly<Props>,
+  ref: React.ForwardedRef<HTMLInputElement>,
+) {
   const { name, options: opts, value, setting } = props;
 
   const options = React.useMemo(
@@ -39,8 +42,11 @@ export default function InputForSingleSelectList(props: Readonly<Props>) {
       isNotClearable
       name={name}
       onChange={props.onChange}
+      ref={ref}
       size={InputSize.Large}
       value={value}
     />
   );
 }
+
+export default React.forwardRef(InputForSingleSelectList);
index 986fb4713eb7c5283eb679f85d68307a4e3459bb..9eb65edc9ad665a3e726800876afa84b8f9fe77d 100644 (file)
@@ -21,6 +21,11 @@ import * as React from 'react';
 import { DefaultSpecializedInputProps } from '../../utils';
 import SimpleInput from './SimpleInput';
 
-export default function InputForString(props: DefaultSpecializedInputProps) {
-  return <SimpleInput size="large" type="text" {...props} />;
+function InputForString(
+  props: DefaultSpecializedInputProps,
+  ref: React.ForwardedRef<HTMLInputElement>,
+) {
+  return <SimpleInput ref={ref} size="large" type="text" {...props} />;
 }
+
+export default React.forwardRef(InputForString);
index 4c15764bb7339bd91ea8d69f2c1d37b4bfec7844..43002fdef8de39fa9251f81f66196dcdc5ceb251 100644 (file)
@@ -21,18 +21,23 @@ import { InputTextArea } from 'design-system';
 import * as React from 'react';
 import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
-export default class InputForText extends React.PureComponent<DefaultSpecializedInputProps> {
+interface Props extends DefaultSpecializedInputProps {
+  innerRef: React.ForwardedRef<HTMLTextAreaElement>;
+}
+
+class InputForText extends React.PureComponent<Props> {
   handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
     this.props.onChange(event.target.value);
   };
 
   render() {
-    const { setting, name, value } = this.props;
+    const { setting, name, innerRef, value } = this.props;
     return (
       <InputTextArea
         size="large"
         name={name}
         onChange={this.handleInputChange}
+        ref={innerRef}
         rows={5}
         value={value || ''}
         aria-label={getPropertyName(setting.definition)}
@@ -40,3 +45,9 @@ export default class InputForText extends React.PureComponent<DefaultSpecialized
     );
   }
 }
+
+export default React.forwardRef(
+  (props: DefaultSpecializedInputProps, ref: React.ForwardedRef<HTMLTextAreaElement>) => (
+    <InputForText innerRef={ref} {...props} />
+  ),
+);
index df400bdc67f7d6f7916eb968e58c9edb71c13421..f210200a1134e4aebda44c6cbd5046c8a72f6efc 100644 (file)
@@ -23,7 +23,11 @@ import { translateWithParameters } from '../../../../helpers/l10n';
 import { DefaultSpecializedInputProps, getEmptyValue, getPropertyName } from '../../utils';
 import PrimitiveInput from './PrimitiveInput';
 
-export default class MultiValueInput extends React.PureComponent<DefaultSpecializedInputProps> {
+interface Props extends DefaultSpecializedInputProps {
+  innerRef: React.ForwardedRef<HTMLInputElement>;
+}
+
+class MultiValueInput extends React.PureComponent<Props> {
   ensureValue = () => {
     return this.props.value || [];
   };
@@ -41,14 +45,17 @@ export default class MultiValueInput extends React.PureComponent<DefaultSpeciali
   };
 
   renderInput(value: any, index: number, isLast: boolean) {
-    const { setting, isDefault, name } = this.props;
+    const { ariaDescribedBy, setting, isDefault, name, innerRef } = this.props;
     return (
       <li className="sw-flex sw-items-center sw-mb-2" key={index}>
         <PrimitiveInput
+          ariaDescribedBy={ariaDescribedBy}
+          index={index}
           isDefault={isDefault}
           name={name}
           hasValueChanged={this.props.hasValueChanged}
           onChange={(value) => this.handleSingleInputChange(index, value)}
+          ref={index === 0 ? innerRef : null}
           setting={setting}
           value={value}
         />
@@ -85,3 +92,9 @@ export default class MultiValueInput extends React.PureComponent<DefaultSpeciali
     );
   }
 }
+
+export default React.forwardRef(
+  (props: DefaultSpecializedInputProps, ref: React.ForwardedRef<HTMLInputElement>) => (
+    <MultiValueInput innerRef={ref} {...props} />
+  ),
+);
index e9c8ec6ac389b58666b500a4e48e28879ec01bd1..023e1f2b1c940789e58f78abf487834688736c73 100644 (file)
@@ -29,20 +29,15 @@ import InputForSingleSelectList from './InputForSingleSelectList';
 import InputForString from './InputForString';
 import InputForText from './InputForText';
 
-function withOptions(
-  options: string[],
-): React.ComponentType<React.PropsWithChildren<DefaultSpecializedInputProps>> {
-  return function Wrapped(props: DefaultSpecializedInputProps) {
-    return <InputForSingleSelectList options={options} {...props} />;
-  };
-}
-
-export default function PrimitiveInput(props: DefaultSpecializedInputProps) {
-  const { setting, name, isDefault, ...other } = props;
+function PrimitiveInput(
+  props: DefaultSpecializedInputProps,
+  ref: React.ForwardedRef<HTMLInputElement>,
+) {
+  const { ariaDescribedBy, setting, name, isDefault, ...other } = props;
   const { definition } = setting;
   const typeMapping: {
     [type in SettingType]?: React.ComponentType<
-      React.PropsWithChildren<DefaultSpecializedInputProps>
+      React.PropsWithChildren<DefaultSpecializedInputProps & { options?: string[] }>
     >;
   } = React.useMemo(
     () => ({
@@ -54,13 +49,30 @@ export default function PrimitiveInput(props: DefaultSpecializedInputProps) {
       INTEGER: InputForNumber,
       LONG: InputForNumber,
       FLOAT: InputForNumber,
-      SINGLE_SELECT_LIST: withOptions(definition.options),
+      SINGLE_SELECT_LIST: InputForSingleSelectList,
       FORMATTED_TEXT: InputForFormattedText,
     }),
     [definition.options],
   );
 
   const InputComponent = (definition.type && typeMapping[definition.type]) || InputForString;
+  let id = `input-${name}`;
+  if (typeof props.index === 'number') {
+    id = `${id}-${props.index}`;
+  }
 
-  return <InputComponent isDefault={isDefault} name={name} setting={setting} {...other} />;
+  return (
+    <InputComponent
+      ariaDescribedBy={ariaDescribedBy}
+      id={id}
+      isDefault={isDefault}
+      name={name}
+      options={definition.type === SettingType.SINGLE_SELECT_LIST ? definition.options : undefined}
+      setting={setting}
+      ref={ref}
+      {...other}
+    />
+  );
 }
+
+export default React.forwardRef(PrimitiveInput);
index b500a28137631d2c18c5680862bd521e4c827075..71297a1e665c725e8765baa6755b96196b45a957 100644 (file)
@@ -37,7 +37,11 @@ import {
 } from '../../utils';
 import PrimitiveInput from './PrimitiveInput';
 
-export default class PropertySetInput extends React.PureComponent<DefaultSpecializedInputProps> {
+interface Props extends DefaultSpecializedInputProps {
+  innerRef: React.ForwardedRef<HTMLInputElement>;
+}
+
+class PropertySetInput extends React.PureComponent<Props> {
   ensureValue() {
     return this.props.value || [];
   }
@@ -57,13 +61,13 @@ export default class PropertySetInput extends React.PureComponent<DefaultSpecial
   };
 
   renderFields(fieldValues: any, index: number, isLast: boolean) {
-    const { setting, isDefault } = this.props;
+    const { ariaDescribedBy, setting, isDefault, innerRef } = this.props;
     const { definition } = setting;
 
     return (
       <TableRow key={index}>
         {isCategoryDefinition(definition) &&
-          definition.fields.map((field) => {
+          definition.fields.map((field, idx) => {
             const newSetting = {
               ...setting,
               definition: field,
@@ -72,10 +76,13 @@ export default class PropertySetInput extends React.PureComponent<DefaultSpecial
             return (
               <ContentCell className="sw-py-2 sw-border-0" key={field.key}>
                 <PrimitiveInput
+                  ariaDescribedBy={ariaDescribedBy}
+                  index={index}
                   isDefault={isDefault}
                   hasValueChanged={this.props.hasValueChanged}
                   name={getUniqueName(definition, field.key)}
                   onChange={(value) => this.handleInputChange(index, field.key, value)}
+                  ref={index === 0 && idx === 0 ? innerRef : null}
                   setting={newSetting}
                   size="full"
                   value={fieldValues[field.key]}
@@ -145,3 +152,9 @@ export default class PropertySetInput extends React.PureComponent<DefaultSpecial
     );
   }
 }
+
+export default React.forwardRef(
+  (props: DefaultSpecializedInputProps, ref: React.ForwardedRef<HTMLInputElement>) => (
+    <PropertySetInput innerRef={ref} {...props} />
+  ),
+);
index 81c6b1eafaf05dee09ed6c4937146ddd8fce5c84..ae7dd820e89a86871db6a66972e6d9a1e54a5d97 100644 (file)
@@ -23,10 +23,11 @@ import { KeyboardKeys } from '../../../../helpers/keycodes';
 import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
 export interface SimpleInputProps extends DefaultSpecializedInputProps {
+  innerRef: React.ForwardedRef<HTMLInputElement>;
   value: string | number;
 }
 
-export default class SimpleInput extends React.PureComponent<SimpleInputProps> {
+class SimpleInput extends React.PureComponent<SimpleInputProps> {
   handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     this.props.onChange(event.currentTarget.value);
   };
@@ -41,9 +42,12 @@ export default class SimpleInput extends React.PureComponent<SimpleInputProps> {
 
   render() {
     const {
+      ariaDescribedBy,
       autoComplete,
       autoFocus,
       className,
+      index,
+      innerRef,
       isInvalid,
       name,
       value = '',
@@ -51,8 +55,16 @@ export default class SimpleInput extends React.PureComponent<SimpleInputProps> {
       size,
       type,
     } = this.props;
+
+    let label = getPropertyName(setting.definition);
+    if (typeof index === 'number') {
+      label = label.concat(` - ${index + 1}`);
+    }
+
     return (
       <InputField
+        aria-describedby={ariaDescribedBy}
+        id={`input-${name}-${index}`}
         isInvalid={isInvalid}
         autoComplete={autoComplete}
         autoFocus={autoFocus}
@@ -60,11 +72,18 @@ export default class SimpleInput extends React.PureComponent<SimpleInputProps> {
         name={name}
         onChange={this.handleInputChange}
         onKeyDown={this.handleKeyDown}
+        ref={innerRef}
         type={type}
         value={value}
         size={size}
-        aria-label={getPropertyName(setting.definition)}
+        aria-label={label}
       />
     );
   }
 }
+
+export default React.forwardRef(
+  (props: Omit<SimpleInputProps, 'innerRef'>, ref: React.ForwardedRef<HTMLInputElement>) => (
+    <SimpleInput innerRef={ref} {...props} />
+  ),
+);
index f2ba7bdcf6ae0a5df4a96eae45e461d07ed9beab..3154efa57d9557e09d550f432d3d481b690b489f 100644 (file)
@@ -41,14 +41,17 @@ export const DEFAULT_CATEGORY = 'general';
 export type DefaultSpecializedInputProps = DefaultInputProps & {
   autoComplete?: string;
   className?: string;
+  index?: number;
   isDefault: boolean;
   name: string;
   type?: string;
 };
 
 export interface DefaultInputProps {
+  ariaDescribedBy?: string;
   autoFocus?: boolean;
   hasValueChanged?: boolean;
+  id?: string;
   isEditing?: boolean;
   isInvalid?: boolean;
   onCancel?: () => void;
index 102fbef632ae1bafeb2d4e4a22206a1e74e9dd3a..9295d0724d0855094f8426ebdde6d64819895759 100644 (file)
@@ -1452,6 +1452,7 @@ settings.analysis_scope.wildcards.introduction=You can use the following wildcar
 settings.analysis_scope.wildcards.zero_more_char=Match zero or more characters
 settings.analysis_scope.wildcards.zero_more_dir=Match zero or more directories
 settings.analysis_scope.wildcards.single_char=Match a single character
+settings.analysis_scope.learn_more=Read more about analysis scope
 
 settings.new_code_period.category=New Code
 settings.new_code_period.title=New Code