yamoo9 / likelion-FEQA

질문/답변 — 프론트엔드 스쿨, 멋사
29 stars 9 forks source link

[LAB-6] 웹 접근성에 관한 이슈 #303

Closed SSY1203 closed 1 year ago

SSY1203 commented 1 year ago

질문 작성자

신선영

문제 상황

웹 접근성에 관해서 이슈가 있어서 문의드립니다. Open WAX를 써서 웹 접근성을 준수할려고 시도 중에

image

노란 동그라미 부분처럼 bg 이미지의 대체 텍스트가 백그라운드 넣은 div 안에 텍스트들이 모두 적힙니다. 이를 해결할 수 있는 방법이 있을까요? Open WAX에서는 큰 의미가 없으면 넘어가라는 식으로 적혀있는데,

const HomePage = () => {
  const [modal, setModal] = useState(false);

  const navigate = useNavigate();

  const { signOut } = useSignOut();
  const { createAuthUser, isLoading, error } = useCreateAuthUser('users');
  const { readData, data } = useReadData('users');

  const handleModal = () => {
    setModal(!modal);
  };

  window.onload = () => {
    signOut();
    localStorage.clear();
  };

  const handleLoginClick = async () => {
    const { user } = await signInWithPopup(auth, googleProvider);
    const { uid } = user;

    localStorage.setItem('uid', JSON.stringify(uid));

    await createAuthUser(user);
    await readData(uid);

    navigate('/make-tree');
  };

  return (
    <>
      <div className={style.homeContainer}> // (1)
        <div className={style.homeLogo}>
          <div className={style.homeMainTitle}>
            <figure className={style.moonLogo}>
              <A11yHidden as={'figcaption'}>
                초승달과 벚꽃이 함께있는 모양의 아이콘입니다.
              </A11yHidden>
            </figure>
            <h1 className={style.homeTitleInfo}>
              <span aria-hidden className={style.homeTitleShadow}>
                벚꽃이 지면
              </span>
              <span aria-hidden className={style.homeTitleBorder}>
                벚꽃이 지면
              </span>
              <span className={style.homeTitle}>벚꽃이 지면</span>
            </h1>
          </div>
          <h2 className={style.homeSubTitle}>
            벚꽃이 지면 당신의 메세지가 전달됩니다.
          </h2>
        </div>
        <div className={style.tree}>
          <figure className={style.blossomTree}>
            <A11yHidden as={'figcaption'}>
              벚꽃이지면 프로젝트의 메인 벚꽃나무 이미지입니다.
            </A11yHidden>
          </figure>
          <ProjectInfoButton handleModal={handleModal} />
        </div>
        <div className={style.loginButtonList}>
          <LoginButton
            className={style.generalButton}
            onClick={() => navigate('/signin')}
            text="로그인"
          />
          <LoginButton
            className={style.googleButton}
            provider={googleProvider}
            onClick={handleLoginClick}
            text={'구글 계정으로 계속하기'}
          />
        </div>
      </div>
      {modal ? <ModalProjectInfo handleModal={handleModal} /> : null}
    </>
  );
};

(1)번 부분에 css로 백그라운드를 넣어줬는데 div 안에 있는 텍스트 모두가 대체 텍스트로 잡힙니다. 무시해도 되는 부분인지 혹은 해결 가능한 부분인지 궁금합니다!

그리고 두 번째 질문은

SignInPage.jsx

export default function SignInPage() {
  const [renderNotification, setRenderNotification] = useState(false);
  const [notificationAriaLive, setNotificationAriaLive] = useState('off');
  const [notificationRole, setNotificationRole] = useState();

  const formStateRef = useRef(initialFormState);
  const notificationRef = useRef();

  const navigate = useNavigate();

  const { isLoading: isLoadingSignIn, signIn } = useSignIn();
  const { isLoading, error, user } = useAuthState();

  const handleSignIn = async (e) => {
    e.preventDefault();

    await setRenderNotification(true);

    const { email, password } = formStateRef.current;
    const notification = notificationRef.current;

    await signIn(email, password);

    if (!user) {
      notification.classList.add(style.animateNotification);
      setNotificationRole('alert');
      setNotificationAriaLive('assertive');
      setTimeout(() => {
        notification.classList.remove(style.animateNotification);
        setNotificationRole();
        setNotificationAriaLive('off');
        setRenderNotification(false);
      }, 2000);
    }
  };

  const handleChangeInput = (e) => {
    const { name, value } = e.target;
    formStateRef.current[name] = value;

    if (
      name === 'email' &&
      value.includes('@') &&
      value.substring(0, value.indexOf('@')) !== '' &&
      value.substring(value.indexOf('@') + 1) !== '' &&
      value.substring(value.indexOf('@') + 1).includes('.') &&
      value.substring(0, value.indexOf('.')) !== '' &&
      value.substring(value.indexOf('.') + 1) !== '' &&
      value.substring(value.indexOf('.') - 1, value.indexOf('.')) !== '@'
    ) {
      e.target.nextSibling.classList.add(style.validatePassed);
    } else if (
      name === 'email' &&
      (!value.includes('@') ||
        value.substring(0, value.indexOf('@')) === '' ||
        value.substring(value.indexOf('@') + 1) === '' ||
        !value.substring(value.indexOf('@') + 1).includes('.') ||
        value.substring(0, value.indexOf('.')) === '' ||
        value.substring(value.indexOf('.') + 1) === '' ||
        value.substring(value.indexOf('.') - 1, value.indexOf('.')) === '@')
    ) {
      e.target.nextSibling.classList.remove(style.validatePassed);
    }

    if (name === 'password' && value.trim().length > 5) {
      e.target.nextSibling.classList.add(style.validatePassed);
    } else if (name === 'password' && (!value || value.trim().length < 6)) {
      e.target.nextSibling.classList.remove(style.validatePassed);
    }
  };

  if (isLoading) {
    return <div role="alert">페이지를 준비 중입니다.</div>;
  }

  if (error) {
    return <div role="alert">오류! {error.message}</div>;
  }

  if (user) {
    localStorage.setItem('uid', JSON.stringify(user.uid));

    navigate('/make-tree');
  }

  return (
    <>
      <A11yHidden as={'h1'}>벚꽃이지면</A11yHidden>
      <div className={style.signInPageWrapper}>
        <div className={style.signInPageContainer}>
          <h2 className={style.signInPageTitle}>로그인</h2>

          <form className={style.form} onSubmit={handleSignIn}>
            {renderNotification ? (
              <Notification
                className={style.notificationStyling}
                text={'이메일 또는 비밀번호를 확인해주세요 !'}
                notificationRef={notificationRef}
                notificationRole={notificationRole}
                notificationAriaLive={notificationAriaLive}
              />
            ) : (
              ''
            )}
            <FormInput
              name="email"
              type="email"
              label="이메일"
              onChange={handleChangeInput}
            />

            <FormInput
              name="password"
              type="password"
              label="비밀번호"
              onChange={handleChangeInput}
            />

            <button
              type="submit"
              disabled={isLoadingSignIn}
              className={style.signInButton}
              aria-label="로그인하기"
            >
              {!isLoadingSignIn ? '로그인' : '로그인 중...'}
            </button>
          </form>
          <button
            type="button"
            onClick={() => navigate('/signup')}
            className={style.toSignUpPage}
            aria-label="회원가입 페이지로 이동"
          >
            회원가입
          </button>
          <p className={style.toSignUpPageWithDescription} aria-hidden="true">
            가입한 계정이 없다면{' '}
            <Link to="/signup" className={style.toSignUpPageLink} tabIndex={-1}>
              회원가입
            </Link>
            을 해주세요 !
          </p>
          <button
            type="button"
            onClick={() => navigate('/')}
            className={style.toHomePage}
            aria-label="이전 페이지로 이동"
          >
            {`<`}
          </button>
        </div>
      </div>
    </>
  );
}

FormInput.jsx

export function FormInput({
  name,
  label,
  type,
  invisibleLabel,
  vertical,
  ...restProps
}) {
  const [visible, setVisible] = useState(false);
  const [passwordType, setPasswordType] = useState(type);

  const inputRef = useRef(null);

  const id = useId();

  useEffect(() => {
    const input = inputRef.current;
    const component = input.parentElement;

    input.addEventListener('blur', (e) => {
      if (e.target.value.length > 0) {
        component.classList.add(style.inputed);
      } else {
        component.classList.remove(style.inputed);
      }
    });
  }, []);

  const combineClassNames = `${style.FormInput} ${
    vertical ? style.FormInputVertical : ''
  }`.trim();

  const handlePasswordVisibility = () => {
    const input = inputRef.current;

    if (passwordType === 'text') {
      setPasswordType('password');
    } else if (passwordType === 'password') {
      setPasswordType('text');
    }
    setVisible((visible) => !visible);
    input.focus();
  };

  return (
    <div className={combineClassNames}>
      {renderLabel(id, label, invisibleLabel)}

      {((name === 'password' || name === 'passwordConfirm') && visible) ||
      ((name === 'password' || name === 'passwordConfirm') && !visible) ? (
        <input
          name={name}
          ref={inputRef}
          id={id}
          type={passwordType}
          className={style.input}
          {...restProps}
        />
      ) : (
        ''
      )}
      {!(name === 'password' || name === 'passwordConfirm') ? (
        <input
          name={name}
          ref={inputRef}
          id={id}
          type={type}
          className={style.input}
          {...restProps}
        />
      ) : (
        ''
      )}

      {name === 'name' ||
      name === 'email' ||
      name === 'password' ||
      name === 'passwordConfirm' ? (
        <figure className={style.validate}></figure>
      ) : (
        ''
      )}

      {(name === 'password' && visible) ||
      (name === 'passwordConfirm' && visible) ? (
        <button
          type="button"
          className={style.passwordVisible}
          onClick={handlePasswordVisibility}
          aria-label="비밀번호 숨기기"
        ></button>
      ) : (
        ''
      )}
      {(name === 'password' && !visible) ||
      (name === 'passwordConfirm' && !visible) ? (
        <button
          type="button"
          className={style.passwordInvisible}
          onClick={handlePasswordVisibility}
          aria-label="비밀번호 보기"
        ></button>
      ) : (
        ''
      )}
    </div>
  );
}

FormInput.defualtProps = {
  type: 'text',
  invisibleLabel: false,
  vertical: false,
  inputed: false,
};

FormInput.propTypes = {
  type: string,
  label: string.isRequired,
  invisibleLabel: bool,
  vertical: bool,
  inputed: bool,
};

function renderLabel(id, label, invisibleLabel) {
  return invisibleLabel ? (
    <A11yHidden as="label" htmlFor={id} className={style.label}>
      {label}
    </A11yHidden>
  ) : (
    <label htmlFor={id} className={style.label}>
      {label}
    </label>
  );
}

위 두 코드에 의해서 아래의 그림처럼 웹이 완성되는데 내레이터로 읽으면 이메일 이메일 이런 식으로 두 번 읽히게 되는데 이런 경우에는 어떻게 해결 해야 할까요ㅠ 도무지 감이 안 잡혀서 이슈 남깁니다!

image

프로젝트 저장소 URL

환경 정보

yamoo9 commented 1 year ago

답변 1

Open WAX 가이드에서 안내 하듯 "핑크빛 구름 배경 이미지"는 의미를 가지지 않는 배경 이미지라 접근성 이슈가 아닙니다.

이 검사는 배경 이미지로 사용된 그래픽에 의미를 가지는 콘텐츠가 포함된 경우 대체 텍스트를 제공하는가를 테스트 할 목적으로 사용됩니다.

답변 2

"이메일 이메일" 2번 읽은 이유는 레이블(label)이 "이메일", 인풋 타입(input type)이 "email"이기 때문입니다.

렌더링 된 FormInput 컴포넌트의 HTML 구조는 접근성에 문제가 없습니다.

접근성 트리 뷰(Accessibility Tree View)로 전환해 보면 인풋 요소의 레이블로 잘 처리된 것을 볼 수 있습니다.

Voice Over(Mac) 스크린 리더로 인풋 텍스트 필드에 접근할 경우 "이메일, 텍스트 필드 이메일"로 읽습니다.

만약 레이블이 "사용자 계정"인 경우는 "사용자 계정, 텍스트 편집 이메일"로 읽습니다.

SSY1203 commented 1 year ago

완전 이해 됐어요~ 감사합니다!!😀