]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19245 Improve accessibility of the SelectionCard component
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Mon, 22 May 2023 13:08:47 +0000 (15:08 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 22 May 2023 20:02:56 +0000 (20:02 +0000)
server/sonar-web/design-system/src/components/RadioButton.tsx
server/sonar-web/design-system/src/components/SelectionCard.tsx
server/sonar-web/design-system/src/components/__tests__/SelectionCard-test.tsx

index a49eb04eb18d01bcdd82f40f0f901d8cb909cd1a..5dd58756d73408dd8718e49c1b746e9bd2774294 100644 (file)
@@ -89,6 +89,7 @@ export const RadioButtonStyled = styled.input`
     outline: ${themeBorder('focus', 'radioFocusOutline')};
   }
 
+  &.is-checked,
   &:focus:checked,
   &:focus-visible:checked,
   &:hover:checked,
@@ -100,6 +101,7 @@ export const RadioButtonStyled = styled.input`
     border: ${themeBorder('default', 'radioBorder')};
   }
 
+  &.is-disabled,
   &:disabled {
     background: ${themeColor('radioDisabledBackground')};
     border: ${themeBorder('default', 'radioDisabledBorder')};
@@ -107,6 +109,7 @@ export const RadioButtonStyled = styled.input`
 
     ${tw`sw-cursor-not-allowed`}
 
+    &.is-checked,
     &:checked {
       background-image: linear-gradient(
           to right,
index 9f7fb7efe2dd5108013069260d1711ec0f36d259..31df423e578e52680baaaa000fe8ea83a13edb6b 100644 (file)
  */
 import styled from '@emotion/styled';
 import classNames from 'classnames';
-import { noop } from 'lodash';
 import tw from 'twin.macro';
 import { translate } from '../helpers/l10n';
 import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
-import { RadioButton } from './RadioButton';
+import { RadioButtonStyled } from './RadioButton';
 import { LightLabel } from './Text';
 import { RecommendedIcon } from './icons/RecommendedIcon';
 
@@ -55,7 +54,9 @@ export function SelectionCard(props: SelectionCardProps) {
   } = props;
   const isActionable = Boolean(onClick);
   return (
-    <Wrapper
+    <StyledButton
+      aria-checked={selected}
+      aria-disabled={disabled}
       className={classNames(
         'js-radio-card',
         {
@@ -67,35 +68,39 @@ export function SelectionCard(props: SelectionCardProps) {
         className
       )}
       onClick={isActionable && !disabled ? onClick : undefined}
-      tabIndex={0}
+      role={isActionable ? 'radio' : 'presentation'}
+      tabIndex={disabled ? -1 : 0}
     >
-      <Content>
+      <StyledContent>
         {isActionable && (
           <div className="sw-items-start sw-mt-1/2 sw-mr-2">
-            <RadioButton checked={selected} disabled={disabled} onCheck={noop} value={title} />
+            <RadioButtonStyled
+              as="i"
+              className={classNames({ 'is-checked': selected, 'is-disabled': disabled })}
+            />
           </div>
         )}
         <div>
-          <Header>
+          <StyledLabel>
             {title}
             <LightLabel>{titleInfo}</LightLabel>
-          </Header>
-          <Body>{children}</Body>
+          </StyledLabel>
+          <StyledBody>{children}</StyledBody>
         </div>
-      </Content>
+      </StyledContent>
       {recommended && (
-        <Recommended>
+        <StyledRecommended>
           <StyledRecommendedIcon className="sw-mr-1" />
           <span className="sw-align-middle">
             <strong>{translate('recommended')}</strong> {recommendedReason}
           </span>
-        </Recommended>
+        </StyledRecommended>
       )}
-    </Wrapper>
+    </StyledButton>
   );
 }
 
-const Wrapper = styled.div`
+const StyledButton = styled.button`
   ${tw`sw-relative sw-flex sw-flex-col`}
   ${tw`sw-rounded-2`}
   ${tw`sw-box-border`}
@@ -105,6 +110,8 @@ const Wrapper = styled.div`
 
   &:focus {
     outline: none;
+    border: ${themeBorder('default', 'selectionCardBorderHover')};
+    box-shadow: ${themeShadow('sm')};
   }
 
   &.card-vertical {
@@ -133,12 +140,13 @@ const Wrapper = styled.div`
   }
 `;
 
-const Content = styled.div`
+const StyledContent = styled.div`
   ${tw`sw-my-4 sw-mx-3`}
   ${tw`sw-flex sw-grow`}
+  ${tw`sw-text-left`}
 `;
 
-const Recommended = styled.div`
+const StyledRecommended = styled.div`
   ${tw`sw-body-sm`}
   ${tw`sw-py-2 sw-px-4`}
   ${tw`sw-box-border`}
@@ -153,8 +161,8 @@ const StyledRecommendedIcon = styled(RecommendedIcon)`
   ${tw`sw-align-middle`}
 `;
 
-const Header = styled.h2`
-  ${tw`sw-flex sw-items-center`}
+const StyledLabel = styled.label`
+  ${tw`sw-flex`}
   ${tw`sw-mb-3 sw-gap-2`}
   ${tw`sw-body-sm-highlight`}
 
@@ -165,7 +173,7 @@ const Header = styled.h2`
   }
 `;
 
-const Body = styled.div`
+const StyledBody = styled.div`
   ${tw`sw-flex sw-grow`}
   ${tw`sw-flex-col sw-justify-between`}
 `;
index ef37745d39231930ade67bf4d0823666d34c673c..3239dadd0c4a85b616072ff556be6c61c7f35e11 100644 (file)
@@ -26,7 +26,7 @@ import { SelectionCard } from '../SelectionCard';
 it('should render option and be actionnable', async () => {
   const user = userEvent.setup();
   const onClick = jest.fn();
-  setupWithProps({
+  renderSelectionCard({
     onClick,
     children: <>The Option</>,
   });
@@ -43,7 +43,7 @@ it('should render option and be actionnable', async () => {
 it('should not be actionnable when disabled', async () => {
   const user = userEvent.setup();
   const onClick = jest.fn();
-  setupWithProps({
+  renderSelectionCard({
     onClick,
     disabled: true,
     children: <>The Option</>,
@@ -58,7 +58,7 @@ it('should not be actionnable when disabled', async () => {
 });
 
 it('should not be actionnable when no click handler', () => {
-  setupWithProps({
+  renderSelectionCard({
     children: <>The Option</>,
   });
 
@@ -66,7 +66,7 @@ it('should not be actionnable when no click handler', () => {
   expect(screen.queryByRole('radio')).not.toBeInTheDocument();
 });
 
-function setupWithProps(props: Partial<FCProps<typeof SelectionCard>> = {}) {
+function renderSelectionCard(props: Partial<FCProps<typeof SelectionCard>> = {}) {
   return render(
     <SelectionCard
       recommended={true}