본문 바로가기
오픈소스

@radix-ui 훑어보기 - accordion

by 위그든씨 2024. 3. 24.

@radix-ui/accorian

 

primitives/packages/react/accordion/src/Accordion.tsx at main · radix-ui/primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos. - radix-ui/primitives

github.com

// 사용하는 법
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