Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] 접근성 개선 작업인 모달, 포커스, 스크린리더 개선을 진행한다. #814

Merged
merged 15 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ private*
test-results/
playwright-report/
blob-report/
playwright/.auth/*
playwright/.cache/
*/.auth
.auth

4 changes: 2 additions & 2 deletions frontend/src/components/ChecklistList/CustomBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const CustomBanner = ({ onClick }: Props) => {
<S.Banner onClick={onClick}>
<S.Wrapper>
<PencilIcon width={30} height={30} aria-hidden="true" />
<S.Title>체크리스트 질문</S.Title>
<S.Title>체크리스트 질문 선택하기</S.Title>
</S.Wrapper>
<S.Button>편집하기</S.Button>
<S.Button aria-label="체크리스트 질문을 편집하려면 이 버튼을 누르세요.">편집하기</S.Button>
</S.Banner>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,37 @@ import { ChecklistQuestionWithIsSelected } from '@/types/checklist';

const QuestionSelectCard = ({ question }: { question: ChecklistQuestionWithIsSelected }) => {
const { title, subtitle, isSelected, questionId } = question;
const { toggleQuestionSelect } = useChecklistQuestionSelect();
const { toggleQuestionSelect, statusMessage } = useChecklistQuestionSelect();
const { currentTabId: categoryId } = useTabContext();

const handleCheckQuestion = () => {
toggleQuestionSelect({ questionId, isSelected: !isSelected, categoryId });
};

return (
<S.Container isChecked={isSelected} onClick={handleCheckQuestion} className="question">
<S.FlexColumn>
<FlexBox.Vertical>
<S.Title>{title}</S.Title>
{subtitle && <S.Subtitle>{subtitle}</S.Subtitle>}
</FlexBox.Vertical>
</S.FlexColumn>
<S.CheckBoxContainer>
<Checkbox iconType="plus" isChecked={isSelected} onClick={handleCheckQuestion} />
</S.CheckBoxContainer>
</S.Container>
<>
<S.Container
isChecked={isSelected}
onClick={handleCheckQuestion}
aria-label={`${title} ${subtitle ?? ''} 해당 질문을 선택하려면 두번 탭하세요.`}
tabIndex={0}
>
<S.FlexColumn aria-hidden="true" tabIndex={-1}>
<FlexBox.Vertical>
<S.Title>{title}</S.Title>
{subtitle && <S.Subtitle>{subtitle}</S.Subtitle>}
</FlexBox.Vertical>
</S.FlexColumn>
<S.CheckBoxContainer aria-hidden="true" tabIndex={-1}>
<Checkbox iconType="plus" isChecked={isSelected} onClick={handleCheckQuestion} />
</S.CheckBoxContainer>
</S.Container>
{statusMessage && (
<div className="visually-hidden" role="alert">
{title + statusMessage}
</div>
)}
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const ChecklistQuestionItem = ({ question, answer }: Props) => {
<HighlightText title={title} highlights={highlights} />
</S.Question>
<S.Options>
<ChecklistQuestionAnswers answer={answer} questionId={questionId} />
<ChecklistQuestionAnswers title={title} answer={answer} questionId={questionId} />
</S.Options>
</S.Container>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { Answer, AnswerType } from '@/types/answer';
interface Props {
questionId: number;
answer: AnswerType;
title: string;
}

const ChecklistQuestionAnswers = ({ questionId, answer }: Props) => {
const ChecklistQuestionAnswers = ({ questionId, answer, title }: Props) => {
const { currentTabId } = useTabContext();
const { toggleAnswer } = useChecklistQuestionAnswer();
const { toggleAnswer, statusMessage } = useChecklistQuestionAnswer();

const handleClick = useCallback(
(newAnswer: AnswerType) => {
Expand All @@ -38,6 +39,11 @@ const ChecklistQuestionAnswers = ({ questionId, answer }: Props) => {
/>
);
})}
{statusMessage && (
<div className="visually-hidden" role="alert">
{title + statusMessage}
</div>
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ const OptionAllSelectBox = () => {
const selectedOptions = useSelectedOptionStore(state => state.selectedOptions);
const selectedOptionActions = useSelectedOptionStore(state => state.actions);

const handleToggleAllSelect = selectedOptionActions.isAllSelected()
? selectedOptionActions.removeAll
: selectedOptionActions.addAllOptions;
const isAllSelected = selectedOptionActions.isAllSelected();
const handleToggleAllSelect = isAllSelected ? selectedOptionActions.removeAll : selectedOptionActions.addAllOptions;

return (
<S.ButtonContainer>
<S.TotalSelectBox>
{/*전체 선택 버튼*/}
<Checkbox
isChecked={selectedOptionActions.isAllSelected()}
ariaLabel={`전체 옵션을 선택 ${isAllSelected ? '해제' : ''}하려면 두번 누르세요.`}
isChecked={isAllSelected}
onClick={handleToggleAllSelect}
color={theme.palette.yellow500}
hoverColor={theme.palette.yellow600}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import React from 'react';
import React, { useState } from 'react';

import useSelectedOptionStore from '@/store/useSelectedOptionStore';
import { flexCenter, flexColumn } from '@/styles/common';
Expand All @@ -10,8 +10,17 @@ const OptionButton = ({ option, isSelected }: { option: OptionWithIcon; isSelect
const { FilledIcon, UnFilledIcon, displayName, id } = option;

const selectedOptionActions = useSelectedOptionStore(state => state.actions);

const handleClick = isSelected ? () => selectedOptionActions.remove(id) : () => selectedOptionActions.add(id);
const [statusMessage, setStatusMessage] = useState('');

const handleClick = () => {
if (isSelected) {
selectedOptionActions.remove(id);
setStatusMessage(`${option.displayName} 선택 취소되었습니다.`);
} else {
selectedOptionActions.add(id);
setStatusMessage(`${option.displayName} 선택되었습니다.`);
}
};

if (!option) {
return null;
Expand All @@ -33,10 +42,27 @@ const OptionButton = ({ option, isSelected }: { option: OptionWithIcon; isSelect
const currentColor = isSelected ? BUTTON_COLOR.selected : BUTTON_COLOR.unSelected;

return (
<S.Box id={option.name} color={currentColor.fill} borderColor={currentColor.border} onClick={handleClick}>
<S.IconBox>{isSelected ? <FilledIcon aria-hidden="true" /> : <UnFilledIcon aria-hidden="true" />}</S.IconBox>
<S.TextBox color={currentColor.text}>{displayName}</S.TextBox>
</S.Box>
<>
<S.Box
id={option.name}
color={currentColor.fill}
borderColor={currentColor.border}
onClick={handleClick}
aria-label={
!isSelected
? `${displayName}을 옵션에 추가하려면 두번 탭하세요.`
: `${displayName}을 옵션에서 해제하려면 두번 탭하세요.`
}
>
<S.IconBox>{isSelected ? <FilledIcon aria-hidden="true" /> : <UnFilledIcon aria-hidden="true" />}</S.IconBox>
<S.TextBox aria-hidden color={currentColor.text}>
{displayName}
</S.TextBox>
</S.Box>
<div className="visually-hidden" aria-live="assertive">
{statusMessage}
</div>
</>
);
};

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/_common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import theme from '@/styles/theme';

type ButtonSize = 'xSmall' | 'small' | 'medium' | 'full';
type ColorOption = 'light' | 'dark' | 'disabled';
type ButtonType = 'button' | 'submit' | 'reset';

interface Props extends React.HTMLAttributes<HTMLButtonElement> {
size?: ButtonSize;
Expand All @@ -18,6 +19,7 @@ interface Props extends React.HTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
Icon?: FunctionComponent<SVGProps<SVGSVGElement>>;
id?: string;
type?: ButtonType;
}

const Button = ({
Expand All @@ -27,6 +29,7 @@ const Button = ({
isSquare = false,
onClick = () => {},
disabled,
type = 'button',
id,
Icon,
...rest
Expand All @@ -49,6 +52,7 @@ const Button = ({
disabled={disabled}
aria-label={label}
tabIndex={1}
type={type}
>
<FlexBox.Horizontal>
{Icon && <Icon aria-hidden="true" />}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/_common/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface StyledProps extends React.InputHTMLAttributes<HTMLInputElement> {
hoverColor?: string;
onClick?: () => void;
iconType?: 'check' | 'plus';
ariaLabel?: string;
}

const Checkbox = ({
Expand All @@ -18,11 +19,12 @@ const Checkbox = ({
hoverColor,
iconType = 'check',
onClick,
ariaLabel,
}: StyledProps) => {
const checkedColor = isChecked ? color || theme.palette.green500 : theme.palette.grey400;

return (
<S.Checkbox $color={checkedColor} $hoverColor={hoverColor} onClick={onClick}>
<S.Checkbox $color={checkedColor} $hoverColor={hoverColor} onClick={onClick} aria-label={ariaLabel}>
<S.FlexBox>
{iconType === 'check' ? <CheckIcon aria-hidden="true" /> : <PlusWhite aria-hidden="true" />}
</S.FlexBox>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const AlertModal = ({
<Modal.body>
<S.Container>
<S.IconBox>{hasIcon && <BangBangCryIcon width={70} height={70} />}</S.IconBox>
<S.Title>{title}</S.Title>
{subtitle && <S.subtitle>{subtitle}</S.subtitle>}
<S.Title tabIndex={0}>{title}</S.Title>
{subtitle && <S.subtitle tabIndex={0}>{subtitle}</S.subtitle>}
</S.Container>
</Modal.body>
<Modal.footer>
Expand Down
30 changes: 21 additions & 9 deletions frontend/src/components/_common/Tabs/TabButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import '@/styles/category-sprite-image.css';

import styled from '@emotion/styled';
import React from 'react';
import React, { forwardRef } from 'react';

import { flexCenter, title3 } from '@/styles/common';
import { Tab } from '@/types/tab';
Expand All @@ -10,16 +10,28 @@ interface Props extends Tab {
onMoveTab: (id: number) => void;
active: boolean;
isCompleted?: boolean;
tabIndex?: number;
}

const TabButton = ({ id, onMoveTab, name, active, className, isCompleted }: Props) => {
return (
<S.Container className={'tab'} key={id} onClick={() => onMoveTab(id)} active={active}>
<S.TextBox className={className && `sprite-icon ${className}`}>{name}</S.TextBox>
{isCompleted === false && <S.UncompletedIndicator />}
</S.Container>
);
};
const TabButton = forwardRef<HTMLDivElement, Props>(
({ id, onMoveTab, name, active, className, isCompleted, ...rest }, ref) => {
return (
<S.Container
className={`tab ${className || ''}`}
onClick={() => onMoveTab(id)}
active={active}
role="tab"
ref={ref}
{...rest}
>
<S.TextBox className={className ? `sprite-icon ${className}` : ''}>{name}</S.TextBox>
{isCompleted === false && <S.UncompletedIndicator />}
</S.Container>
);
},
);

TabButton.displayName = 'TabButton';

export default React.memo(TabButton);

Expand Down
33 changes: 29 additions & 4 deletions frontend/src/components/_common/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';

import TabButton from '@/components/_common/Tabs/TabButton';
import { useTabContext } from '@/components/_common/Tabs/TabContext';
Expand All @@ -12,29 +12,54 @@ interface Props {
const Tabs = ({ tabList }: Props) => {
const { currentTabId, setCurrentTabId } = useTabContext();

const tabRefs = useRef<(HTMLDivElement | null)[]>([]);

useEffect(() => {
if (tabRefs.current[currentTabId]) {
tabRefs.current[currentTabId]?.focus();
}
}, [currentTabId]);

const onMoveTab = useCallback(
(tabId: number) => {
setCurrentTabId(tabId);
},
[setCurrentTabId],
);

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabList.findIndex(tab => tab.id === currentTabId);

if (e.key === 'ArrowRight') {
if (currentIndex === tabList.length - 1) return;
const nextIndex = (currentIndex + 1) % tabList.length;
setCurrentTabId(tabList[nextIndex].id);
} else if (e.key === 'ArrowLeft') {
if (currentIndex === 0) return;
const prevIndex = (currentIndex - 1 + tabList.length) % tabList.length;
setCurrentTabId(tabList[prevIndex].id);
}
};

return (
<S.VisibleContainer>
<S.VisibleContainer role="navigation" aria-label="탭 내비게이션">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nav 태그를 사용하면 role이 자동으로 들어가기때문에 또넣어줄 필요가 없습니다.

<S.Container>
<S.FlexContainer>
{tabList?.map(tab => {
<S.FlexContainer role="tablist" onKeyDown={handleKeyDown}>
{tabList?.map((tab, index) => {
const { id, name, className } = tab;
const isCompleted = 'isCompleted' in tab ? tab.isCompleted : undefined;

return (
<TabButton
ref={el => (tabRefs.current[index - 1] = el)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게도 되는줄 몰랐네요

className={className}
id={id}
name={name}
onMoveTab={onMoveTab}
key={id}
active={tab.id === currentTabId}
tabIndex={tab.id === currentTabId ? 0 : -1}
aria-selected={tab.id === currentTabId}
isCompleted={isCompleted}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/_common/TipBox/TipBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const TipBox = ({ tipType }: Props) => {
return (
<S.TipBox>
<S.TipText>
💡 <S.Bold>TIP</S.Bold> : {TIP_MESSAGE[tipType]}
<span aria-hidden="true">💡</span> <S.Bold>TIP</S.Bold> : {TIP_MESSAGE[tipType]}
</S.TipText>
<CloseIcon onClick={closeTip} style={{ paddingRight: 1 }} aria-label="클릭하면 팁박스가 삭제됩니다" />
</S.TipBox>
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/hooks/useAutoLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,14 @@ const useAutoLogin = () => {
if (isRefreshTokenExist) {
if (!isAccessTokenExist) {
try {
const accessTokenReissueResult = await postReissueAccessToken();
if (accessTokenReissueResult?.status !== 200) return;
await postReissueAccessToken();
const result = await getUserInfo();
showToast({ message: `${result?.userName}님, 환영합니다.`, type: 'confirm' });
return navigate(ROUTE_PATH.home);
} catch (err) {
return await deleteToken();
}
}

const result = await getUserInfo();
showToast({ message: `${result?.userName}님, 환영합니다.`, type: 'confirm' });
return navigate(ROUTE_PATH.home);
}
};

Expand Down
Loading
Loading