// 사용하는 법
import * as Accordion from '@radix-ui/react-accordion';
import React from 'react';
import classNames from 'classnames';
import { ChevronDownIcon } from '@radix-ui/react-icons';
export Default () => (
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Content />
</Accordion.Item>
</Accordion.Root>
);
const AccordionDemo = () => (
<Accordion.Root
className="bg-mauve6 w-[300px] rounded-md shadow-[0_2px_10px] shadow-black/5"
type="single"
defaultValue="item-1"
collapsible
>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it unstyled?</AccordionTrigger>
<AccordionContent>
Yes. It's unstyled by default, giving you freedom over the look and feel.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can it be animated?</AccordionTrigger>
<AccordionContent>
Yes! You can animate the Accordion with CSS or JavaScript.
</AccordionContent>
</AccordionItem>
</Accordion.Root>
);
const AccordionItem = React.forwardRef(({ children, className, ...props }, forwardedRef) => (
<Accordion.Item
className={classNames(
'focus-within:shadow-mauve12 mt-px overflow-hidden first:mt-0 first:rounded-t last:rounded-b focus-within:relative focus-within:z-10 focus-within:shadow-[0_0_0_2px]',
className
)}
{...props}
ref={forwardedRef}
>
{children}
</Accordion.Item>
));
const AccordionTrigger = React.forwardRef(({ children, className, ...props }, forwardedRef) => (
<Accordion.Header className="flex">
<Accordion.Trigger
className={classNames(
'text-violet11 shadow-mauve6 hover:bg-mauve2 group flex h-[45px] flex-1 cursor-default items-center justify-between bg-white px-5 text-[15px] leading-none shadow-[0_1px_0] outline-none',
className
)}
{...props}
ref={forwardedRef}
>
{children}
<ChevronDownIcon
className="text-violet10 ease-[cubic-bezier(0.87,_0,_0.13,_1)] transition-transform duration-300 group-data-[state=open]:rotate-180"
aria-hidden
/>
</Accordion.Trigger>
</Accordion.Header>
));
const AccordionContent = React.forwardRef(({ children, className, ...props }, forwardedRef) => (
<Accordion.Content
className={classNames(
'text-mauve11 bg-mauve2 data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp overflow-hidden text-[15px]',
className
)}
{...props}
ref={forwardedRef}
>
<div className="py-[15px] px-5">{children}</div>
</Accordion.Content>
));
export default AccordionDemo;
// Accordion에서 export 될 요소들
const Root = Accordion;
const Item = AccordionItem;
const Header = AccordionHeader;
const Trigger = AccordionTrigger;
const Content = AccordionContent;
export {
createAccordionScope,
//
Accordion,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
//
Root,
Item,
Header,
Trigger,
Content,
};
export type {
AccordionSingleProps,
AccordionMultipleProps,
AccordionItemProps,
AccordionHeaderProps,
AccordionTriggerProps,
AccordionContentProps,
};
import { createCollection } from '@radix-ui/react-collection';
// 컴포넌트 내에서 자식 요소들의 컬렉션을 관리하기 위한 hook & utility제공
type AccordionTriggerElement = React.ElementRef<typeof CollapsiblePrimitive.Trigger>;
// 아코디언 트리거 타입은 CollapsiblePrimitve의 트리거 컴포넌트의 요소 참조.
const ACCORDION_NAME = 'Accordion';
const ACCORDION_KEYS = ['Home', 'End', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'];
const [Collection, useCollection, createCollectionScope] = createCollection<AccordionTriggerElement>(ACCORDION_NAME);
// createCollection 함수의 반환값
// 제네릭으로 받는 ACCORDIAN_NAME을 통해 개발자 도구에서 식별하기 쉽게 함
// Collection : 컬렉션의 구현체 - 컴포넌트 트리에서 이 컬렉션을 사용하여 자식 요소들을 감싸기 가능
// useCollection: 컬렉션 내의 항목에 대한 정보 가져오는 훅. eg) 특정 항목이 포커스 중인지
// createCollectionScrope : 컬렉션의 범위를 정의하는데 사용. 컬렉션을 여러 레벨에 걸쳐 분리할 때 유용
import { createContextScope } from '@radix-ui/react-context';
import { createCollapsibleScope } from '@radix-ui/react-collapsible';
const [createAccordionContext, createAccordionScope] = createContextScope(ACCORDION_NAME, [
createCollectionScope,
createCollapsibleScope,
]);
// createContextScope : 데이터를 전역 관리 - [createCollectionScope, createCollapsibleScope]는 Accordion 컴포넌트가 컬렉션과 접힘(콜랩서블) 기능을 포함하도록 설정
// createAccodionContext: 컨텍스트 생성
// createAccodionScope : 해당 컨텍스트의 스코프 정의
const useCollapsibleScope = createCollapsibleScope();
// createCollapsibleScope(): 접히는 기능을 가진 컴포넌트를 위한 컨텍스트 스코프 생성
// useCollapsibleScope: 컴포넌트 내에서 해당 스코프를 사용하여 관련 컨텍스트를 쉽게 접근하고 사용
type AccordionImplElement = React.ElementRef<typeof Primitive.div>;
type AccordionImplSingleElement = AccordionImplElement
type AccordionImplMultipleElement = AccordionImplElement;
type AccordionElement = AccordionImplMultipleElement | AccordionImplSingleElement;
import { Primitive } from '@radix-ui/react-primitive';
// Primitive는 Radix UI가 제공하는 기본적인 UI 구성 요소를 포함하는 객체
import type * as Radix from '@radix-ui/react-primitive';
type PrimitiveDivProps = Radix.ComponentPropsWithoutRef<typeof Primitive.div>;
interface AccordionImplProps extends PrimitiveDivProps {
/**
* Whether or not an accordion is disabled from user interaction.
*
* @defaultValue false
*/
disabled?: boolean;
/**
* The layout in which the Accordion operates.
* @default vertical
*/
orientation?: React.AriaAttributes['aria-orientation'];
/**
* The language read direction.
*/
dir?: Direction;
}
interface AccordionImplSingleProps extends AccordionImplProps {
/**
* The controlled stateful value of the accordion item whose content is expanded.
*/
value?: string;
/**
* The value of the item whose content is expanded when the accordion is initially rendered. Use
* `defaultValue` if you do not need to control the state of an accordion.
*/
defaultValue?: string;
/**
* The callback that fires when the state of the accordion changes.
*/
onValueChange?(value: string): void;
/**
* Whether an accordion item can be collapsed after it has been opened.
* @default false
*/
collapsible?: boolean;
}
interface AccordionImplMultipleProps extends AccordionImplProps {
/**
* The controlled stateful value of the accordion items whose contents are expanded.
*/
value?: string[];
/**
* The value of the items whose contents are expanded when the accordion is initially rendered. Use
* `defaultValue` if you do not need to control the state of an accordion.
*/
defaultValue?: string[];
/**
* The callback that fires when the state of the accordion changes.
*/
onValueChange?(value: string[]): void;
}
interface AccordionSingleProps extends AccordionImplSingleProps {
type: 'single';
}
interface AccordionMultipleProps extends AccordionImplMultipleProps {
type: 'multiple';
}
// Accordion.Root
import type { Scope } from '@radix-ui/react-context';
// Scope : 같은 페이지 내에서 여러 개의 독립적인 아포넌트 컴포넌트가 있을 때 각 아코디언의 상태를 분리하기 위함
type ScopedProps<P> = P & { __scopeAccordion?: Scope };
// AccordionElement: ref로 전달 될 수 있는 요소의 타입
// AccordionSingleProps | AccordionMultipleProps : 아코디언 컴포넌트가 받을 수 있는 속성의 타입
const Accordion = React.forwardRef<AccordionElement, AccordionSingleProps | AccordionMultipleProps>(
(props: ScopedProps<AccordionSingleProps | AccordionMultipleProps>, forwardedRef) => {
const { type, ...accordionProps } = props;
const singleProps = accordionProps as AccordionImplSingleProps;
const multipleProps = accordionProps as AccordionImplMultipleProps;
return (
//Collection 컴포넌트는 위에서 언급한 createCollection 함수의 반환값
<Collection.Provider scope={props.__scopeAccordion}>
{type === 'multiple' ? (
<AccordionImplMultiple {...multipleProps} ref={forwardedRef} />
) : (
<AccordionImplSingle {...singleProps} ref={forwardedRef} />
)}
</Collection.Provider>
);
}
);
// const ACCORDION_NAME = 'Accordion';
Accordion.displayName = ACCORDION_NAME;
//propTypes는 컴포넌트에 전달되는 props의 유효성을 검사하기 위함(리액트의 내장 기능)-개발 모드에서 사용
Accordion.propTypes = {
type(props) {
const value = props.value || props.defaultValue;
// 싱글이나 멀티인지 검사
if (props.type && !['single', 'multiple'].includes(props.type)) {
return new Error(
'Invalid prop `type` supplied to `Accordion`. Expected one of `single | multiple`.'
);
}
//멀티인데 스트링 값 들어온건 아닌지
if (props.type === 'multiple' && typeof value === 'string') {
return new Error(
'Invalid prop `type` supplied to `Accordion`. Expected `single` when `defaultValue` or `value` is type `string`.'
);
}
//싱글인데 배열 들어온건 아닌지
if (props.type === 'single' && Array.isArray(value)) {
return new Error(
'Invalid prop `type` supplied to `Accordion`. Expected `multiple` when `defaultValue` or `value` is type `string[]`.'
);
}
return null;
},
};
type AccordionValueContextValue = {
value: string[]; // 현재 열린 아코디언 항목의 식별자 저장
onItemOpen(value: string): void;
onItemClose(value: string): void;
};
// createAccordionContext는 위에서 createContextScope로 반환 된 것
const [AccordionValueProvider, useAccordionValueContext] = createAccordionContext<AccordionValueContextValue>(ACCORDION_NAME);
// AccordionValueProvider: 컨텍스트의 값을 제공하는 컴포넌트- 하위 컴포넌트들은 useAccordionValueContext 훅을 통해 이 값을 접근할 수 있음.
// useAccordionValueContext: 컨텍스트의 값을 소비하는 훅- 컴포넌트 내에서 이 훅을 사용하면, 현재 컨텍스트의 값을 가져올 수 있음
const [AccordionCollapsibleProvider, useAccordionCollapsibleContext] = createAccordionContext(
ACCORDION_NAME,
{ collapsible: false }
);
// 아코디언의 접힘(collapsible) 관련 컨텍스트를 생성
import { useControllableState } from '@radix-ui/react-use-controllable-state';
// useControllableState : 컨트롤 가능 상태 관리 - 부모 컴포넌트로부터 상태를 받아 사용할 수도 있는 상태
// ==> controlled(부모에 의해 받은 props로 직접 관리) / uncontrolled(부모는 초기 값만 제공) 모드를 쉽게 전환
const AccordionImplSingle = React.forwardRef<AccordionImplSingleElement, AccordionImplSingleProps>(
(props: ScopedProps<AccordionImplSingleProps>, forwardedRef) => {
const {
value: valueProp,
defaultValue,
onValueChange = () => {},
collapsible = false,
...accordionSingleProps
} = props; //SingleProps에 대한 것은 위에 있음
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
});
return (
<AccordionValueProvider
scope={props.__scopeAccordion}
value={value ? [value] : []}
onItemOpen={setValue}
onItemClose={React.useCallback(() => collapsible && setValue(''), [collapsible, setValue])}
>
<AccordionCollapsibleProvider scope={props.__scopeAccordion} collapsible={collapsible}>
<AccordionImpl {...accordionSingleProps} ref={forwardedRef} />
</AccordionCollapsibleProvider>
</AccordionValueProvider>
);
}
);
const AccordionImplMultiple = React.forwardRef<
AccordionImplMultipleElement,
AccordionImplMultipleProps
>((props: ScopedProps<AccordionImplMultipleProps>, forwardedRef) => {
const {
value: valueProp,
defaultValue,
onValueChange = () => {},
...accordionMultipleProps
} = props;
const [value = [], setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
});
const handleItemOpen = React.useCallback(
(itemValue: string) => setValue((prevValue = []) => [...prevValue, itemValue]),
[setValue]
);
const handleItemClose = React.useCallback(
(itemValue: string) =>
setValue((prevValue = []) => prevValue.filter((value) => value !== itemValue)),
[setValue]
);
return (
<AccordionValueProvider
scope={props.__scopeAccordion}
value={value}
onItemOpen={handleItemOpen}
onItemClose={handleItemClose}
>
<AccordionCollapsibleProvider scope={props.__scopeAccordion} collapsible={true}>
<AccordionImpl {...accordionMultipleProps} ref={forwardedRef} />
</AccordionCollapsibleProvider>
</AccordionValueProvider>
);
});
const AccordionImpl = React.forwardRef<AccordionImplElement, AccordionImplProps>(
(props: ScopedProps<AccordionImplProps>, forwardedRef) => {
const { __scopeAccordion, disabled, dir, orientation = 'vertical', ...accordionProps } = props;
const accordionRef = React.useRef<AccordionImplElement>(null);
const composedRefs = useComposedRefs(accordionRef, forwardedRef);
const getItems = useCollection(__scopeAccordion);
const direction = useDirection(dir);
const isDirectionLTR = direction === 'ltr';
const handleKeyDown = composeEventHandlers(props.onKeyDown, (event) => {
if (!ACCORDION_KEYS.includes(event.key)) return;
const target = event.target as HTMLElement;
const triggerCollection = getItems().filter((item) => !item.ref.current?.disabled);
const triggerIndex = triggerCollection.findIndex((item) => item.ref.current === target);
const triggerCount = triggerCollection.length;
if (triggerIndex === -1) return;
// Prevents page scroll while user is navigating
event.preventDefault();
let nextIndex = triggerIndex;
const homeIndex = 0;
const endIndex = triggerCount - 1;
const moveNext = () => {
nextIndex = triggerIndex + 1;
if (nextIndex > endIndex) {
nextIndex = homeIndex;
}
};
const movePrev = () => {
nextIndex = triggerIndex - 1;
if (nextIndex < homeIndex) {
nextIndex = endIndex;
}
};
switch (event.key) {
case 'Home':
nextIndex = homeIndex;
break;
case 'End':
nextIndex = endIndex;
break;
case 'ArrowRight':
if (orientation === 'horizontal') {
if (isDirectionLTR) {
moveNext();
} else {
movePrev();
}
}
break;
case 'ArrowDown':
if (orientation === 'vertical') {
moveNext();
}
break;
case 'ArrowLeft':
if (orientation === 'horizontal') {
if (isDirectionLTR) {
movePrev();
} else {
moveNext();
}
}
break;
case 'ArrowUp':
if (orientation === 'vertical') {
movePrev();
}
break;
}
const clampedIndex = nextIndex % triggerCount;
triggerCollection[clampedIndex].ref.current?.focus();
});
return (
<AccordionImplProvider
scope={__scopeAccordion}
disabled={disabled}
direction={dir}
orientation={orientation}
>
<Collection.Slot scope={__scopeAccordion}>
<Primitive.div
{...accordionProps}
data-orientation={orientation}
ref={composedRefs}
onKeyDown={disabled ? undefined : handleKeyDown}
/>
</Collection.Slot>
</AccordionImplProvider>
);
}
);
/* -------------------------------------------------------------------------------------------------
* AccordionItem
* -----------------------------------------------------------------------------------------------*/
const ITEM_NAME = 'AccordionItem';
type AccordionItemContextValue = { open?: boolean; disabled?: boolean; triggerId: string };
const [AccordionItemProvider, useAccordionItemContext] =
createAccordionContext<AccordionItemContextValue>(ITEM_NAME);
type AccordionItemElement = React.ElementRef<typeof CollapsiblePrimitive.Root>;
type CollapsibleProps = Radix.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Root>;
interface AccordionItemProps
extends Omit<CollapsibleProps, 'open' | 'defaultOpen' | 'onOpenChange'> {
/**
* Whether or not an accordion item is disabled from user interaction.
*
* @defaultValue false
*/
disabled?: boolean;
/**
* A string value for the accordion item. All items within an accordion should use a unique value.
*/
value: string;
}
/**
* `AccordionItem` contains all of the parts of a collapsible section inside of an `Accordion`.
*/
const AccordionItem = React.forwardRef<AccordionItemElement, AccordionItemProps>(
(props: ScopedProps<AccordionItemProps>, forwardedRef) => {
const { __scopeAccordion, value, ...accordionItemProps } = props;
const accordionContext = useAccordionContext(ITEM_NAME, __scopeAccordion);
const valueContext = useAccordionValueContext(ITEM_NAME, __scopeAccordion);
const collapsibleScope = useCollapsibleScope(__scopeAccordion);
const triggerId = useId();
const open = (value && valueContext.value.includes(value)) || false;
const disabled = accordionContext.disabled || props.disabled;
return (
<AccordionItemProvider
scope={__scopeAccordion}
open={open}
disabled={disabled}
triggerId={triggerId}
>
<CollapsiblePrimitive.Root
data-orientation={accordionContext.orientation}
data-state={getState(open)}
{...collapsibleScope}
{...accordionItemProps}
ref={forwardedRef}
disabled={disabled}
open={open}
onOpenChange={(open) => {
if (open) {
valueContext.onItemOpen(value);
} else {
valueContext.onItemClose(value);
}
}}
/>
</AccordionItemProvider>
);
}
);
AccordionItem.displayName = ITEM_NAME;
/* -------------------------------------------------------------------------------------------------
* AccordionHeader
* -----------------------------------------------------------------------------------------------*/
const HEADER_NAME = 'AccordionHeader';
type AccordionHeaderElement = React.ElementRef<typeof Primitive.h3>;
type PrimitiveHeading3Props = Radix.ComponentPropsWithoutRef<typeof Primitive.h3>;
interface AccordionHeaderProps extends PrimitiveHeading3Props {}
/**
* `AccordionHeader` contains the content for the parts of an `AccordionItem` that will be visible
* whether or not its content is collapsed.
*/
const AccordionHeader = React.forwardRef<AccordionHeaderElement, AccordionHeaderProps>(
(props: ScopedProps<AccordionHeaderProps>, forwardedRef) => {
const { __scopeAccordion, ...headerProps } = props;
const accordionContext = useAccordionContext(ACCORDION_NAME, __scopeAccordion);
const itemContext = useAccordionItemContext(HEADER_NAME, __scopeAccordion);
return (
<Primitive.h3
data-orientation={accordionContext.orientation}
data-state={getState(itemContext.open)}
data-disabled={itemContext.disabled ? '' : undefined}
{...headerProps}
ref={forwardedRef}
/>
);
}
);
AccordionHeader.displayName = HEADER_NAME;
/* -------------------------------------------------------------------------------------------------
* AccordionTrigger
* -----------------------------------------------------------------------------------------------*/
const TRIGGER_NAME = 'AccordionTrigger';
type AccordionTriggerElement = React.ElementRef<typeof CollapsiblePrimitive.Trigger>;
type CollapsibleTriggerProps = Radix.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger>;
interface AccordionTriggerProps extends CollapsibleTriggerProps {}
/**
* `AccordionTrigger` is the trigger that toggles the collapsed state of an `AccordionItem`. It
* should always be nested inside of an `AccordionHeader`.
*/
const AccordionTrigger = React.forwardRef<AccordionTriggerElement, AccordionTriggerProps>(
(props: ScopedProps<AccordionTriggerProps>, forwardedRef) => {
const { __scopeAccordion, ...triggerProps } = props;
const accordionContext = useAccordionContext(ACCORDION_NAME, __scopeAccordion);
const itemContext = useAccordionItemContext(TRIGGER_NAME, __scopeAccordion);
const collapsibleContext = useAccordionCollapsibleContext(TRIGGER_NAME, __scopeAccordion);
const collapsibleScope = useCollapsibleScope(__scopeAccordion);
return (
<Collection.ItemSlot scope={__scopeAccordion}>
<CollapsiblePrimitive.Trigger
aria-disabled={(itemContext.open && !collapsibleContext.collapsible) || undefined}
data-orientation={accordionContext.orientation}
id={itemContext.triggerId}
{...collapsibleScope}
{...triggerProps}
ref={forwardedRef}
/>
</Collection.ItemSlot>
);
}
);
AccordionTrigger.displayName = TRIGGER_NAME;
/* -------------------------------------------------------------------------------------------------
* AccordionContent
* -----------------------------------------------------------------------------------------------*/
const CONTENT_NAME = 'AccordionContent';
type AccordionContentElement = React.ElementRef<typeof CollapsiblePrimitive.Content>;
type CollapsibleContentProps = Radix.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>;
interface AccordionContentProps extends CollapsibleContentProps {}
/**
* `AccordionContent` contains the collapsible content for an `AccordionItem`.
*/
const AccordionContent = React.forwardRef<AccordionContentElement, AccordionContentProps>(
(props: ScopedProps<AccordionContentProps>, forwardedRef) => {
const { __scopeAccordion, ...contentProps } = props;
const accordionContext = useAccordionContext(ACCORDION_NAME, __scopeAccordion);
const itemContext = useAccordionItemContext(CONTENT_NAME, __scopeAccordion);
const collapsibleScope = useCollapsibleScope(__scopeAccordion);
return (
<CollapsiblePrimitive.Content
role="region"
aria-labelledby={itemContext.triggerId}
data-orientation={accordionContext.orientation}
{...collapsibleScope}
{...contentProps}
ref={forwardedRef}
style={{
['--radix-accordion-content-height' as any]: 'var(--radix-collapsible-content-height)',
['--radix-accordion-content-width' as any]: 'var(--radix-collapsible-content-width)',
...props.style,
}}
/>
);
}
);
AccordionContent.displayName = CONTENT_NAME;
'오픈소스' 카테고리의 다른 글
[npm] package.json 옵션 정리 *main, module, exports, type, types (0) | 2024.01.10 |
---|---|
npm에 배포해보기 (1) | 2024.01.09 |