오늘은 Accordion 컴포넌트를 리팩토링 하는 시간을 가지겠습니다.
제가 구현한 기존의 Accordion 컴포넌트를 소개하자면 아래와 같습니다.
// Accordion.tsx
const Accordion = ({
children,
allowMultiple,
allowToggle,
onChange,
defaultIndex = -1,
}: AccordionProps) => {
const indexArray: number[] = Array.isArray(defaultIndex)
? defaultIndex
: [defaultIndex];
const [indexes, setIndexes] = useState<number[]>(indexArray || []);
const appendIndex = (index: number) => {
setIndexes((prevIndexes) => [...prevIndexes, index]);
};
const removeIndex = (index: number) => {
setIndexes((prevIndexes) =>
prevIndexes.filter((prevIndex) => prevIndex !== index),
);
};
const resetIndex = () => {
setIndexes([]);
};
const prop = {
onChange,
allowMultiple,
allowToggle,
appendIndex,
removeIndex,
resetIndex,
indexes,
};
return (
<div className={accordion}>
<AccordionContext.Provider value={prop}>
{React.Children.map(children, (child, index) =>
React.isValidElement(child)
? React.cloneElement(child, {
...child.props,
index,
})
: child,
)}
</AccordionContext.Provider>
</div>
);
};
// AccordionItem.tsx
const AccordionItem = ({ children, index, isDisabled }: Props) => {
return (
<div className={item({ isDisabled })}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
...child.props,
index,
isDisabled,
});
}
return child;
})}
</div>
);
};
// AccordionButton.tsx
const AccordionButton = ({ children, index = 0, isDisabled }: Props) => {
const {
allowMultiple,
allowToggle,
onChange,
removeIndex,
appendIndex,
resetIndex,
indexes,
} = useContext(AccordionContext);
const handleButtonClick = (index: number) => {
if (onChange) {
onChange();
}
const isInclude = indexes?.includes(index);
if (!allowMultiple && !allowToggle && !isInclude) {
resetIndex();
appendIndex(index);
return;
}
if (allowToggle && isInclude) {
removeIndex(index);
return;
}
if (allowToggle && !isInclude) {
resetIndex();
appendIndex(index);
return;
}
if (allowMultiple && isInclude) {
removeIndex(index);
return;
}
if (allowMultiple && !isInclude) {
appendIndex(index);
return;
}
if (!allowMultiple && !allowToggle && isInclude) {
return;
}
};
return (
<button
onClick={() => handleButtonClick(index)}
className={button}
disabled={isDisabled}
>
{children}
{indexes?.includes(index) ? <IoIosArrowUp /> : <IoIosArrowDown />}
</button>
);
};
// AccordionPanel.tsx
const AccordionPanel = ({ children, index = 0 }: Props) => {
const { indexes } = useContext(AccordionContext);
return (
<div className={panel({ isOpen: indexes?.includes(index) })}>
{children}
</div>
);
};
// App.tsx
<Accordion allowToggle defaultIndex={[0]}>
<AccordionItem isDisabled>
<AccordionButton>1번 제목</AccordionButton>
<AccordionPanel>
1번 내용
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>2번 제목</AccordionButton>
<AccordionPanel>
2번 내용
</AccordionPanel>
</AccordionItem>
</Accordion>
합성 컴포넌트 패턴으로 구성되어 있으며 아래와 같은 요구사항을 충족하도록 기능 구현이 되어있습니다.
Chakra UI라는 라이브러리를 기준으로 기능을 구현했지만 몇 가지 아쉬운 점이 있습니다.
- 웹 접근성이 전혀 고려되지 않았습니다.
- react에서 권장하지 않는 레거시 API인 cloneElement와 Children을 사용했습니다.
- 다른 컴포넌트에서도 사용되는 반복되는 로직이 컴포넌트 내부에 있습니다.
이러한 부분을 고려하여 리팩토링을 진행해보겠습니다.
해당 링크에 가보시면 Accordion 컴포넌트를 구현할 때 어떠한 점을 고려해서 구현해야 하는지 나와 있습니다.
// AccordionButton.tsx
...
return (
<button
ref={ref}
role="button"
aria-expanded={isInclude}
aria-controls={id}
aria-disabled={!allowToggle && !allowMultiple && isInclude}
onClick={() => handleButtonClick(value)}
className={button}
>
{children}
{values?.includes(value) ? <IoIosArrowUp /> : <IoIosArrowDown />}
</button>
);
...
AccordionButton 컴포넌트에 role="button"을 부여해야합니다.
AccordionButton 컴포넌트와 연결되어 있는 AccordionPanel 컴포넌트가 열려있다면 aria-expanded 속성이 true 닫혀있다면 false를 부여해야합니다.
AccordionButton 컴포넌트와 연결된 AccordionPanel 컴포넌트의 id 값을 aria-controles에 부여해야합니다.
AccordionButton 컴포넌트를 클릭해도 AccordionPanel 컴포넌트를 열거나 닫을 수 없을 때 aria-disabled 속성에 false를 부여해야 합니다.
// Accordion.tsx
...
const accordionRefs = useRef<HTMLElement[]>([]);
const handleKeyDown = (
event: KeyboardEvent<HTMLElement>,
) => {
if (!keyList.includes(event.key)) return;
const length = accordionRefs.current.length;
const target = event.target as HTMLElement;
const currentIndex = accordionRefs.current.indexOf(target);
event.preventDefault();
let nextIndex = 0;
switch (event.key) {
case "ArrowDown":
nextIndex = (currentIndex + 1) % length;
break;
case "ArrowUp":
nextIndex = (currentIndex - 1 + length) % length;
break;
case "ArrowRight":
nextIndex = (currentIndex + 1) % length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + length) % length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = length - 1;
break;
case "Tab":
if (currentIndex < refs.current.length - 1) {
nextIndex = currentIndex + 1;
}
break;
}
changeIndex?.(nextIndex);
accordionRefs.current[nextIndex].focus();
};
...
Accordion 컴포넌트에서 keyboard 이벤트가 발생하면 특정 동작을 진행하는 함수입니다.
// AccordionButton.tsx
...
const accordionRef = useRef<HTMLElement>(null);
useEffect(() => {
if (accordionRef?.current && accordionRefs?.current) {
accordionRefs.current.push(accordionRef.current);
return () => {
if (accordionRef.current && accordionRefs.current) {
const index = accordionRefs.current.indexOf(accordionRef.current);
if (index !== -1) {
accordionRefs.current.splice(index, 1);
}
}
};
}
}, []);
...
Accordion 컴포넌트가 렌더링될 때 accordionRef를 accordionRefs에 push하는 로직입니다.
이 2가지 로직을 추가해서 Accordion 컴포넌트에서 keyboard event를 통해 focus를 이동할 수 있게 되었어요!
기존에 사용하던 cloneElement와 Children을 제거하고 Context API를 통해 리팩토링을 진행했습니다.
// Accordion.tsx
...
return (
<AccordionContext.Provider value={value}>
<div className={accordion}>{children}</div>
</AccordionContext.Provider>
);
...
// AccordionItem.tsx
export const AccordionItemContext =
createContext<AccordionItemContextProps | null>(null);
const AccordionItem = forwardRef(
(
{ children, value, isDisabled }: PropsWithChildren<Props>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const id = useId();
return (
<div className={item({ isDisabled })} ref={ref}>
<AccordionItemContext.Provider value={{ value, id }}>
{children}
</AccordionItemContext.Provider>
</div>
);
},
);
지금까지 리팩토링한 Accordion 컴포넌트에서 refs에 ref를 push하는 로직과 keyboard 이벤트를 통해 focus를 이동하는 로직은 다른 컴포넌트에서도 똑같이 사용됩니다.
이러한 로직은 커스텀 훅으로 분리해서 사용하면 더 좋겠죠?
// useCollectRefs.ts
import { RefObject, useEffect, useRef } from "react";
type UseRefs = {
refs: RefObject<HTMLElement[]>;
setter?: (index: number) => void;
};
const useCollectRefs = ({ refs, setter }: UseRefs) => {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (ref?.current && refs?.current) {
refs.current.push(ref.current);
setter?.(refs.current.indexOf(ref.current));
return () => {
if (ref.current && refs.current) {
const index = refs.current.indexOf(ref.current);
if (index !== -1) {
refs.current.splice(index, 1);
}
}
};
}
}, [refs, ref]);
return { ref };
};
export default useCollectRefs;
useCollectRefs라는 커스텀 훅을 만들었고 setter 함수를 받아 현재 index를 state로 관리해야 하는 경우 해당 로직을 수행하도록 했습니다.
// useKeyboardEvent.ts
import { KeyboardEvent, useRef } from "react";
type UseKeybaordEvent = {
keyList: string[];
changeIndex?: (index: number) => void;
};
const useKeyboardEvent = ({ keyList, changeIndex }: UseKeybaordEvent) => {
const refs = useRef<HTMLElement[]>([]);
const handleKeyDown = (
event: KeyboardEvent<HTMLElement>,
callback?: () => void,
) => {
if (!keyList.includes(event.key)) return;
const length = refs.current.length;
const target = event.target as HTMLElement;
const currentIndex = refs.current.indexOf(target);
event.preventDefault();
let nextIndex = 0;
switch (event.key) {
case "ArrowDown":
nextIndex = (currentIndex + 1) % length;
break;
case "ArrowUp":
nextIndex = (currentIndex - 1 + length) % length;
break;
case "ArrowRight":
nextIndex = (currentIndex + 1) % length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + length) % length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = length - 1;
break;
case "Tab":
if (currentIndex < refs.current.length - 1) {
nextIndex = currentIndex + 1;
}
break;
case "Enter":
callback?.();
}
changeIndex?.(nextIndex);
refs.current[nextIndex].focus();
};
return { refs, handleKeyDown };
};
export default useKeyboardEvent;
useKeyboardEvent라는 커스텀 훅을 만들고 keyList를 해당 hook이 trigger할 key를 정하고 만약 추가적으로 수행해야 하는 동작이 있을 경우 changeIndex 또는 callback 함수로 수행하도록 했습니다.
방금 구현한 2개의 Custom hook은 Accordion, Tabs, Menu, PinInput 이렇게 4가지 컴포넌트에서 사용됩니다!
이것으로 1차적인 리팩토링은 끝났습니다. 해당 프로젝트는 꾸준히 업데이트할 예정이기 때문에 이 글을 보실 때는 코드가 지금과 다를 수도 있습니다.
최종적인 코드를 보고 싶다면 이곳을!