일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- 리액트
- 클린코드
- react
- 리팩토링
- 에세이
- sharedworker
- CRA
- radixui
- Web
- 이것저것
- 회고
- 오블완
- context.api
- CustomHook
- MicroFrontEnd
- 합성 컴포넌트
- 티스토리챌린지
- 아키텍처
- virtaullist
- JavaScript
- provider 패턴
- 웹워커
- vite
- MFA
- Webworker
- 자바스크립트
- 프론트엔드
- frontend
- Function Region
- TypeScript
- Today
- Total
Lighthouse of FE beginner
[React] Radix UI를 활용해 MultiSelect 컴포넌트 구현하기 본문
들어가며
이번 포스팅에서는 @radix-ui/react-select 패키지를 활용해 Multiple Select 컴포넌트를 구현해보도록 하겠습니다.
Radix UI
Radix UI는 Headless UI 라이브러리 입니다.
Headless UI 라이브러리란 다른 UI 라이브러리와 다르게 디자인이 입혀진 컴포넌트를 제공하는 것이 아닌 디자인 없이 해당 컴포넌트의 기능만을 제공하는 라이브러리 입니다.
디자인을 제공하는 라이브러리는 MUI, antd, Chakra UI 와 같은 라이브러리가 있습니다.
위 라이브러리 모두 컴포넌트와 기본 디자인을 제공해 디자이너가 없는 프로젝트의 경우 프로젝트를 빠르게 구현할 수 있다는 장점이 있습니다.
하지만 위 라이브러리의 디자인을 커스텀 하려면 공수가 상당히 많이 들어가는 편이고 라이브러리 자체의 번들 사이즈가 생각보다 무겁습니다.
위와 같은 이슈로 요즘은 컴포넌트의 기능만을 제공해주는 Headless UI 라이브러리를 많이 사용하는 추세이고 그 중 Web 표준을 가장 잘 살린 Radix UI 라이브러리를 사용해보도록 하겠습니다.
Radix UI 공식 홈페이지에 들어가보면 Themes, Premitives 라는 탭이 보이는데요, Theme 라이브러리는 다른 라이브러리와 마찬가지로 디자인이 입힌 라이브러리 입니다. Premitives 라이브러리는 이름 그대로 원시인 Headless UI 라이브러리 입니다. 오늘 포스팅해서는 해당 라이브러리를 이용해보도록 하겠습니다.
https://www.radix-ui.com/primitives
Radix-UI Premitives 라이브러리는 독특한 특징이 있습니다. 바로 사용하고자 하는 컴포넌트 (Headless Premitive Component) 패키지별로 설치를 한다는 점 입니다.
이번 포스팅에서는 Select 컴포넌트를 구현할 것이니 해당 패키지부터 설치해보도록 하겠습니다.
설치
먼저 패키지를 설치해줍시다!
pnpm install @radix-ui/react-select
npm install @radix-ui/react-select
yarn add @radix-ui/react-select
컴포넌트 구현
간단한 Select 컴포넌트를 구현하는 방법은 홈페이지에 자세하게 나와있습니다.
https://www.radix-ui.com/primitives/docs/components/select
저는 해당 컴포넌트를 사용해 다중 선택이 가능한 컴포넌트를 구현하고 싶은데 패키지에서 다중 선택을 지원하지 않습니다.
구글링을 통해 살펴보니 이미 예전부터 다중 선택 기능에 대한 이야기가 나왔지만 아직 실제로 패키지에서 지원하고 있지는 않았습니다.
저는 RadixSelect의 Root 컴포넌트에 value를 제공하고 컴포넌트에서 별도로 value를 다루는 change 함수를 작성해 다중 선택 기능을 구현해 볼 예정입니다.
먼저 컴포넌트에서 필요한 기능을 살펴봅시다.
1. 다중 선택 기능
2. 전체 삭제 (클리어) 기능
3. 선택된 요소 삭제 (단일 클리어) 기능
위 기능을 바탕으로 컴포넌트를 설계해보도록 하겠습니다.
1. 컴포넌트를 사용하는 곳에서 값을 다룰 state를 관리한다.
2. Select 컴포넌트에 value와 state를 다룰 change함수를 props로 내려준다.
3. Select 컴포넌트에서는 state를 Radix-ui/Select 패키지의 Root 컴포넌트로 내려준다.
4. 컴포넌트에서 change 함수를 통해 선택된 값이 이미 선택된 값인지, 아닌지 판단해 새로운 value list를 만들어 실제 onChange 함수에 인자로써 넘겨준다.
먼저 기본 컴포넌트를 구현해보도록 하겠습니다.
import * as RadixSelect from '@radix-ui/react-select';
export type SelectProps = {
options: SelectOption[];
rootProps: RadixSelect.SelectProps;
contentProps?: RadixSelect.SelectContentProps;
triggerProps?: {
className?: string;
style?: CSSProperties;
};
placeholder?: string;
loadingMessage?: string;
errorMessage?: string;
useEmptyOption?: boolean;
isLoading?: boolean;
isError?: boolean;
isValidateError?: boolean;
};
export type MultiSelectProps = Omit<SelectProps, 'rootProps' | 'useEmptyOption'> & {
rootProps: Omit<RadixSelect.SelectProps, 'value' | 'onValueChange'> & {
values: string[];
onChangeValues: (values: string[]) => void;
};
};
Select 컴포넌트의 Props입니다. MultiSelect 컴포넌트는 SelectProps를 상속받아 rootProps 타입을 재정의 해 Props의 인프로퍼티로 타이핑 합니다.
Props로 loading, error, validationError 상태를 내려받아서 UI를 선언적으로 분기해줍니다.
Select Option이 열렸는지의 여부도 컴포넌트 자체에 직접 맡기는 것이 아닌 state로 컨트롤 할 예정입니다.
rootProps 내부 객체인 values, onChangeValues가 값을 다루는 객체, 핸들링 함수입니다. 컴포넌트 내부에서 values를 탐색해 선택한 값이 이미 선택된 값인지 아닌지 판단한 후 새로운 value list를 만들어 rootProps의 onChangeValues에 인자로 넘겨줍니다.
import * as RadixSelect from '@radix-ui/react-select';
const { Root, Trigger, Value, Icon, Portal, Content, Viewport, Group, Label, Item } = RadixSelect;
const MultiSelect = (props: MultiSelectProps) => {
const {
options,
triggerProps,
rootProps: { values, onChangeValues, ...rootProps },
contentProps,
isError = false,
isLoading = false,
isValidateError = false,
placeholder = PLACEHOLDER_MSG,
errorMessage = ERROR_MSG,
loadingMessage = LOADING_MSG,
} = props;
const [open, setOpen] = useState<boolean>(false);
const isEmptyValues = values.length === 0;
const onValueChange = (selectedValue: string) => {
let newValue = [...values];
const isSelected = values.some((value) => value === selectedValue);
if (isSelected) {
const filteredValues = values.filter((value) => value !== selectedValue);
newValue = [...filteredValues];
} else {
newValue = [selectedValue, ...newValue];
}
onChangeValues(newValue);
toggleMenu();
};
const toggleMenu = useCallback(() => {
setOpen((prev) => !prev);
}, []);
return (
<Root {...rootProps} disabled={isError || isLoading || rootProps?.disabled} open={open}>
<Trigger
data-multiple
onClick={toggleMenu}
style={triggerProps?.style}
data-isvalidateerror={isValidateError}
>
{isLoading ? (
loadingMessage
) : isError ? (
errorMessage
) : isEmptyValues ? (
<Value placeholder={placeholder}>{placeholder}</Value>
) : (
<div className={styles.triggerViewport}>
{values.map((value) => (
<span key={value}>
<Value>{value}</Value>
</span>
))}
</div>
)}
<Icon>
<img src={selectArrow} />
</Icon>
</Trigger>
<Portal>
<Content
{...contentProps}
aria-multiselectable
sideOffset={contentProps?.sideOffset ?? 5}
position={contentProps?.position ?? 'popper'}
onEscapeKeyDown={toggleMenu}
onPointerDownOutside={toggleMenu}
>
<Viewport>
{options.map((option, index) => {
const isSelected = values.includes(option.value);
const onChange = (value: string) => {
onValueChange(value);
if (option.onChangeValue instanceof Function) {
option.onChangeValue(value);
}
};
return (
<Group key={`${option.value}-${index}`}>
<SelectOption
{...option}
onChangeValue={onChange}
isSelected={isSelected}
/>
</Group>
);
})}
</Viewport>
</Content>
</Portal>
</Root>
);
};
SelectOption 컴포넌트 입니다. options를 map을 활용해 화면에 display 할 때 사용하는 컴포넌트 입니다.
import * as RadixSelect from '@radix-ui/react-select';
export type SelectOption = {
label: ReactNode;
title?: ReactNode;
isSelected?: boolean;
onChangeValue?: (value: string) => void;
} & Omit<RadixSelect.SelectItemProps, 'label' | 'textValue'>;
const SelectOption = memo((props: SelectOptionType) => {
const { title, value, disabled, label, onChangeValue, isSelected } = props;
const onClick = useCallback(
(value: string) => {
if (onChangeValue instanceof Function) {
onChangeValue(value);
}
},
[onChangeValue]
);
return (
<>
{title ? <Label>{title}</Label> : null}
<Item
value={value}
disabled={disabled}
onClick={() => onClick(value)}
data-selected={isSelected}
>
{label}
</Item>
</>
);
});
위 컴포넌트로 기본적인 다중 선택을 하는 Select 컴포넌트가 구현됐습니다. 이제 clear 기능을 구현해봅시다!
clear 기능
clear 기능은 선택된 값을 찾아서 해당 값을 비워주기만 하면 됩니다.
먼저 단일 클리어 핸들링 함수를 정의합니다. 핸들링 함수는 기본 이벤트 핸들러에 바인딩 해줘야 하기 때문에 클로저 함수로 작성했습니다.
type OptionDeleteHandler = (value: string) => React.MouseEventHandler;
const onClearOption: OptionDeleteHandler = (selectedValue) => (event) => {
event.stopPropagation();
const filteredValues = values.filter((value) => value !== selectedValue);
onChangeValues(filteredValues);
};
정의한 함수를 HTML 요소에 이벤트 바인딩 합니다. 그리고 전체 클리어 기능을 위해 아이콘을 만들고 아이콘에 이벤트 함수를 바인딩 합니다.
전체 클리어 함수는 values를 비우기만 하면 되기 때문에 rootProps.onChangeValues 함수에 빈 배열을 넘겨주도록 하겠습니다.
이때 유의해야 할 점은 이벤트는 버블링 된다는 것 입니다. clear 아이콘을 클릭 시 이벤트가 버블링 되어 Trigger 컴포넌트까지 클릭 이벤트가 버블링 됩니다. 이벤트 버블링을 막기위해 stopPropagtion을 해주도록 합시다.
// 스타일을 위한 코드를 제거하지 않았습니다.
<Trigger
ref={triggerRef}
data-multiple
onClick={toggleMenu}
style={triggerProps?.style}
data-isvalidateerror={isValidateError}
>
{isLoading ? (
loadingMessage
) : isError ? (
errorMessage
) : isEmptyValues ? (
<Value placeholder={placeholder}>{placeholder}</Value>
) : (
<div className={styles.triggerViewport}>
{values.map((value) => (
<span key={value} className={styles.selectedLabel}>
<Value>{value}</Value>
<span onClick={onClearOption(value)} className={styles.clearIcon} />
</span>
))}
</div>
)}
{!isEmptyValues ? (
<Icon
style={{ position: 'absolute', right: 30 }}
className={styles.clearIcon}
onClick={(event) => {
event.stopPropagation();
onChangeValues([]);
}}
>
<span />
</Icon>
) : null}
<Icon className={styles.selectIcon}>
<img src={selectArrow} />
</Icon>
</Trigger>;
마지막으로 해야할 일이 있습니다. value 핸들링, options 위치, trigger의 open 여부 등 모든 것을 라이브러리 컴포넌트에 위임하는 것이 아닌 직접 핸들링을 하고 있습니다. 이 경우 Content의 width가 Trigger의 width를 추적하지 못해 다소 어색한 모습의 컴포넌트가 확인됩니다.
Content Width 다루기
어색한 Content의 크기를 Trigger와 동일하게 맞춰주기 위해 ref객체와 state를 활용해봅시다! 아래와 같은 플로우를 거쳐 Content 크기를 다룰 예정입니다.
1. ref 객체와 Trigger의 width를 다룰 state를 선언
2. Trigger에 ref를 바인딩
3. useEffect를 활용해 컴포넌트가 마운트 될 시 width를 확인해 state 변경
4. Content의 width에 state를 바인딩
아래는 크기 측정을 위한 코드만 살려놓은 예시 코드입니다.
export const MultiSelect = (props: MultiSelectProps) => {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [viewportWidth, setViewportWidth] = useState<number>(DEFAULT_VIEWPORT_WIDTH);
// Adjust width of Viewport to equal with width of Trigger
useEffect(() => {
const trigger = triggerRef.current;
if (trigger) {
const { width } = trigger.getBoundingClientRect();
setViewportWidth(width);
}
}, []);
return (
<Root>
<Trigger
ref={triggerRef} // ref 바인딩
>
// 중략
</Trigger>
<Portal>
<Content
style={{ width: viewportWidth }} // 스타일 정의
>
// 중략
</Content>
</Portal>
</Root>
);
};
위 과정을 거친다면 다음과 같은 컴포넌트를 구현할 수 있습니다!
컴포넌트를 사용하는 예제
컴포넌트를 사용하는 간단한 예제입니다.
const sampleOptions: SelectOption[] = [
{
label: 'first',
value: 'first',
},
{
label: 'second',
value: 'second',
},
];
const Default = (args) => {
const [values, setValues] = useState<string[]>([]);
const onChangeValues = (values: string[]) => {
setValues(values);
};
return (
<MultiSelect options={sampleOptions} rootProps={{ values, onChangeValues }} />
);
};
마치며
이번 포스팅을 통해 Radix UI의 Select를 활용해 다중 선택이 가능한 Select를 구현하는 방법을 알아봤습니다.
요즘 Radix UI를 활용해 여러 컴포넌트를 구현하고 있는 만큼 다양한 컴포넌트로 찾아올 계획입니다.
예제 코드는 아래 레포지토리를 참고해주시면 감사하겠습니다.
MultiSelect 구현 코드 확인하기
'[WEB] 프론트엔드' 카테고리의 다른 글
페이지 라우팅? 컴포넌트 분기? (2) | 2024.08.08 |
---|---|
Hello Zustand! (0) | 2024.08.07 |
[React] 사례로 살펴보는 커스텀 훅 - 2 (1) | 2024.07.29 |
[React] 사례로 살펴보는 커스텀 훅 (1) | 2024.07.26 |
ResizeObserver 활용하기 (0) | 2024.07.24 |