Divider 컴포넌트 훔쳐오기
회사 내부 디자인 시스템에 Divider 컴포넌트를 기여해보았습니다.
저희 회사는 React와 Emotion을 사용하기에, 이를 기반으로 Divider 컴포넌트를 만들었습니다.
우리 회사 프로덕트들은 다양한 마케팅 전략을 구사하고 있고, 이에 따라 고객을 현혹시키기 위해 자주 랜딩을 바꾸곤 한다. 그러다보니 한 번 쓰고 말 컴포넌트들이 많이 만들어지는데 그 중 하나가 Divider이다.
레포에서 전체 검색으로 Divider를 검색해보았는데 한 50여개의 styled로 작성된 컴포넌트가 나왔다. 구현 방법도 제각각이었다. 중복되는 코드들을 줄이고 편의성도 높이고자 디자인 시스템에 직접 Divider 컴포넌트를 만들어 기여해보았다.
구현
어떻게 하면 효율적으로 구현할 수 있을지 고민을 많이 했는데, 역시 잘 만들어진 것들을 모방하는 것이 최고라고 생각했다. 취준생 시절, 프로젝트에서 자주 사용하여 익숙했던 Chakra UI가 가장 먼저 떠올랐고, 팀원 분이 자주 칭찬을 하는 Radix UI 또한 떠올랐다.
곧바로 Github으로 달려갔다. 언제나 오픈 소스의 코드들을 차근차근 이해해 나가는 것은 어려운 일이다. 정말 다행인건 Select와 같이 복잡한 컴포넌트와는 달리, Divider의 경우 나름 쉽게 작성되어 있었고 Radix와 Chakra 모두 유사하게 Divider를 구현해놓았다.(Radix의 경우 Divider가 아니라 Separator로 명시되어 있는데, 이는 나중에 설명할 Divider의 접근성과 관련해서 Separator로 지은 듯 하다)
Divider의 경우 보통 수평 구분선인 horizontal 모드와 수직 구분선인 vertical 모드로 구분된다. 그리고 이 둘의 구현은 단지 border의 4가지 방향 중 어떤 것을 사용하느냐에 따라 달라진다. horizontal의 경우 border-bottom 혹은 border-top을, vertical의 경우 border-left 혹은 border-right를 이용하면 쉽게 구현이 가능해진다.
대뜸 작성한 코드를 보자면 다음과 같다.
Divider.tsx
import React, {
ComponentPropsWithoutRef,
ComponentPropsWithRef,
ElementType,
forwardRef,
ReactElement,
} from 'react';
import * as S from './Divider.style';
import { neutralDay } from '@/foundation/color';
export type Orientations = 'horizontal' | 'vertical';
type DividerProps<T extends ElementType = 'hr'> = {
as?: T;
size?: number;
color?: string;
decorative?: boolean;
orientation?: Orientations;
isFlexItem?: boolean;
} & ComponentPropsWithoutRef<T>;
type DividerComponent = <T extends ElementType = 'hr'>(
props: DividerProps<T>,
) => ReactElement | null;
const DEFAULT_ORIENTATION = 'horizontal';
/**
* `Divider`는 수평 또는 수직으로 구분선을 그립니다.
*
* @example <caption>Horizontal</caption>
* <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
* <button>Button</button>
* <Divider />
* <button>Button</button>
* </div>
*
* @example <caption>Vertical: 부모 요소의 높이가 명시적으로 설정되어 있어야 합니다.</caption>
* <div style={{ display: 'flex', gap: '16px' }}>
* <button>Button</button>
* <Divider orientation='vertical' isFlexItem />
* <button>Button</button>
* </div>
*
* ----------------------------------------
*
* @prop {number} [size=1] - 선의 두께를 지정합니다.
* @prop {string} [color=neutralDay.gray90] - 선의 색상을 지정합니다.
* @prop {boolean} [decorative=false] - 스크린 리더에서 이 요소를 무시할지 여부를 결정합니다. 즉 접근성과 관련 없는 장식용이라면 true로 설정합니다.
* @prop {'horizontal'|'vertical'} [orientation='horizontal'] - 선의 방향을 지정합니다. 'horizontal' 또는 'vertical' 값을 가질 수 있습니다.
* @prop {boolean} [isFlexItem=false] - divider가 flex의 item인지 여부를 결정합니다. true로 설정하면 부모 요소의 height를 명시적으로 설정할 필요가 없습니다.
*/
export const Divider: DividerComponent = forwardRef(function Divider<T extends ElementType = 'hr'>(
{
as,
size = 1,
color = neutralDay.gray90,
decorative = false,
orientation = DEFAULT_ORIENTATION,
isFlexItem = false,
...restProps
}: DividerProps<T>,
ref?: ComponentPropsWithRef<T>['ref'],
) {
const semanticProps = decorative
? { role: 'none' }
: { role: 'separator', 'aria-orientation': orientation };
return (
<S.Divider
as={as}
ref={ref}
size={size}
color={color}
orientation={orientation}
isFlexItem={isFlexItem}
{...semanticProps}
{...restProps}
/>
);
});
Divider.style.ts
import styled from '@emotion/styled';
import { Orientations } from './Divider';
function getDividerStyle(
size: number,
color: string,
orientation: Orientations,
isFlexItem: boolean,
) {
return {
horizontal: {
width: '100%',
borderTop: `${size}px solid ${color}`,
},
vertical: {
height: isFlexItem ? 'auto' : '100%',
borderLeft: `${size}px solid ${color}`,
alignSelf: isFlexItem ? 'stretch' : 'auto',
},
}[orientation];
}
export const Divider = styled.hr<{
size: number;
color: string;
orientation: Orientations;
isFlexItem: boolean;
}>`
border: none;
margin: 0;
${({ size, color, orientation, isFlexItem }) =>
getDividerStyle(size, color, orientation, isFlexItem)}
`;
기본 element type은 html에서 구분선을 위해 사용되는 hr 태그로 해두었고, as를 통해 바꿀 수 있도록 해두었다. 인터페이스를 보면 size와 color, orientation, isFlexItem이 있는데 이름에서도 알 수 있듯이 size와 color는 선의 두께와 색깔을 의미하며 orientation은 divider의 방향을 의미한다. isFlexItem은 divider의 orientation이 vertical일 때 유용하게 사용할 수 있는데 이는 밑에서 설명하도록 하겠다.
사소한 css 문제
horizontal은 쉽게 구현이 가능했으나 vertical의 경우가 문제였다. height: 100%로 설정하면 알아서 부모의 높이에 맞춰 divider가 뿅하고 나타날 줄 알았는데 그렇지 않았다. 찾아보니 이럴 경우 부모의 height가 수치로 명시되어 있어야 했다. Radix와 Chakra 또한 마찬가지였다. vertical로 divider를 사용할 경우 부모 요소에 height 수치를 명시해달라고 문서에 나와 있다.
Radix와 Chakra 또한 그러고 잠시 찾아 본 stackoverflow 또한 그렇게 설명하니 별다른 방법이 없다고 판단해 이대로 PR을 올렸었다.
근데 정말 다행히 PR에서 팀원이 좋은 방법을 제시해주었다. 바로 aligh-self를 이용하는 것이었다. 레퍼런스로 Material UI를 알려주셨는데, Material UI Divider의 경우 부모의 display가 flex이고 그 하위에 있는 Divider에 flexItem prop을 true로 설정해주면 따로 부모에 height를 수치로 명시하지 않아도 되었다.
이는 align-self를 stretch로 설정하면 알아서 늘어나게 된다는 것을 이용한 것이었다. 이를 통해 vertical로 divider를 사용할 경우 height를 숫자로 명시해야 하는 불편함을 없앨 수 있었다.
접근성
Radix의 Separator를 보며 감명 받은 점은 접근성이었다. Radix UI의 Separator의 경우 decorative라는 prop이 존재하는데, 이는 이름 그대로 Separator(Divider)를 장식용으로 쓸 것인지, 아니면 말 그대로 진짜 구분선으로 써서 접근성까지 고려할 것인지 결정할 수 있게 해주는 prop이다. Radix Separator의 코드를 보면 decorative가 false이면, 즉 장식용이 아니고 정말 구분선을 위한 것이라면 role이 separator가 되고 orientation이라는 aria 속성도 horizontal 혹은 vertical로 들어가게 되어 있다.
오픈 소스를 보면 생각치 못한 부분도 신경 쓸 수 있게 된다는 점에서 앞으로 오픈 소스를 꾸준히 봐야겠다는 생각이 들었다.
Divider를 만들며
Divider를 만들며 오픈 소스를 많이 참고했는데 여러 사람들이 작성해놓은 좋은 코드를 볼 수 있어 아주 좋은 학습 방법이 될 것이라는 생각이 들었다. Divider 뿐만 아니라 Select와 같이 복잡한 컴포넌트도 겁먹지 말고 차근차근 파헤쳐보아야겠다.