ReactでRadioボタンにデザインを当てようとすると、 display: none を使う方法を紹介している記事が多いが、その方法ではアクセシビリティが消えてしまう。

そこで、以下のページのCheckboxを参考にしてRadioボタンをアクセシビリティを維持したままReactで実装したのでメモ的にコードを残しておこうと思う。

https://medium.com/@colebemis/building-a-checkbox-component-with-react-and-styled-components-8d3aa1d826dd

import { css } from '@emotion/core';
import styled from '@emotion/styled';
import React from 'react';

import * as colors from '@/app/components/styles/colors';

export enum RadioSize {
  Small,
  Large,
}

type RadioProps = {
  label?: string;
  size?: RadioSize;
} & InternalRadioProps;

type InternalRadioProps = JSX.IntrinsicElements['input'];

// Hide checkbox visually but remain accessible to screen readers.
// Source: https://polished.js.org/docs/#hidevisually
const HiddenRadio = styled.input`
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  white-space: nowrap;
  border: 0;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
`;

HiddenRadio.defaultProps = { type: 'radio' };

const Dot = styled.circle`
  cx: 12;
  cy: 12;
  r: 6;
`;

const Circle = styled.circle`
  cx: 12;
  cy: 12;
  r: 10;
  stroke-width: 3;
`;

const CheckedIcon = styled.svg`
  fill: none;

  Circle {
    stroke: ${colors.BLUE_600};
  }

  Dot {
    fill: ${colors.BLUE_600};
  }
`;

CheckedIcon.defaultProps = { viewBox: '0 0 24 24' };

const UncheckedIcon = styled.svg`
  fill: none;

  Circle {
    stroke: ${colors.BLUE_600};
  }
`;

UncheckedIcon.defaultProps = { viewBox: '0 0 24 24' };

const radioSize = (size?: RadioSize) => {
  switch (size) {
    case undefined:
    case RadioSize.Small:
      return css`
        width: 16px;
        height: 16px;
        border-radius: 8px;
      `;
    case RadioSize.Large:
      return css`
        width: 20px;
        height: 20px;
        border-radius: 10px;
      `;
    default: {
      const typeCheck: never = size;
      return typeCheck;
    }
  }
};

type StyledRadioProps = {
  checked?: boolean;
  disabled?: boolean;
  size?: RadioSize;
};

const checkedStyles = css`
  CheckedIcon {
    display: inline;
  }

  UncheckedIcon {
    display: none;
  }
`;

const uncheckedStyles = css`
  CheckedIcon {
    display: none;
  }

  UncheckedIcon {
    display: inline;
  }
`;

const disabledStyles = css`
  Circle {
    stroke: ${colors.DARK_BLUE_200};
  }

  Dot {
    fill: ${colors.DARK_BLUE_200};
  }
`;

const StyledRadio = styled.div<StyledRadioProps>`
  display: inline-block;
  line-height: 0;
  transition: all 150ms;

  ${/* sc-selector */ HiddenRadio}:focus + & {
    box-shadow: 0 0 0 2px ${colors.ORANGE};
  }

  ${/* sc-declaration */ ({ checked }) => (checked ? checkedStyles : uncheckedStyles)}
  ${/* sc-declaration */ ({ disabled }) => disabled && disabledStyles}
  ${/* sc-declaration */ ({ size }) => radioSize(size)}
`;

const RadioContainer = styled.div`
  display: inline-flex;
  align-items: center;
  justify-content: center;
`;

function InternalRadio({ checked, disabled, size, ...props }: InternalRadioProps): JSX.Element {
  return (
    <RadioContainer>
      {/* eslint-disable-next-line react/jsx-props-no-spreading */}
      <HiddenRadio checked={checked} disabled={disabled} {...props} />
      <StyledRadio checked={checked} disabled={disabled} size={size}>
        <CheckedIcon>
          <Circle />
          <Dot />
        </CheckedIcon>
        <UncheckedIcon>
          <Circle />
        </UncheckedIcon>
      </StyledRadio>
    </RadioContainer>
  );
}

const Label = styled.label`
  display: inline-flex;
  align-items: center;
  justify-content: center;
`;

const LabelText = styled.span`
  margin-left: 4px;
`;

export function Radio({ label, ...props }: RadioProps): JSX.Element {
  return (
    <Label>
      {/* eslint-disable-next-line react/jsx-props-no-spreading */}
      <InternalRadio {...props} />
      {label && <LabelText>{label}</LabelText>}
    </Label>
  );
}