간단한 Headless 리액트 컴포넌트를 만들어봅시다. - Switch 편

jinho park·2024년 4월 17일
4

Switch 컴포넌트로 Headless 컴포넌트를 만들어봅니다.
몇가지 기능이 빠질 수도 있지만 ...

Observable

간단한 상태를 저장할 곳을 하나 만들어줍니다.
T 는 상태가 들어갈 곳이에요.

export interface Disposable {
  dispose(): void;
}

export interface Observer<T> {
  (data: T): void;
}

export abstract class Observable<T> implements Disposable {
  protected observers: Observer<T>[] = [];

  protected state: T;

  constructor(state: T) {
    this.state = state;
  }

  subscribe(observer: Observer<T>): void {
    this.observers.push(observer);
  }

  unsubscribe(observer: Observer<T>): void {
    this.observers = this.observers.filter((o) => o !== observer);
  }

  notify(): void {
    this.observers.forEach((o) => o(this.getState()));
  }

  dispose(): void {
    this.observers = [];
  }

  setState(state: Partial<T>, callback?: () => void): void {
    this.state = {
      ...this.state,
      ...state,
    };
    callback?.();
    this.notify();
  }

  getState(): T {
    return this.state;
  }
}

SwitchApi

상태를 실제로 관리하는 Api 클래스를 하나 만듭니다.
이때 Observable 을 상속해서 만들어요.

import { Observable } from './Observable';

export type SwitchSizeType = 'xs' | 'sm' | 'md' | 'lg';

export interface SwitchState {
  isChecked?: boolean;
  checked?: boolean;
  defaultChecked?: boolean;
  colorPalette?: string;
  disabled?: boolean;
  size: SwitchSizeType;
}

export interface SwitchApiOptions {
  checked?: boolean;
  defaultChecked?: boolean;
  colorPalette?: string;
  disabled?: boolean;
  size: SwitchSizeType;
  onChange?: (checked: boolean) => void;
}

export class SwitchApi extends Observable<SwitchState> {
  onChange?: (checked: boolean) => void;

  constructor(options: SwitchApiOptions) {
    super({
      checked: options.checked,
      defaultChecked: options.defaultChecked,
      colorPalette: options.colorPalette,
      disabled: options.disabled,
      size: options.size,
      isChecked: options.checked || options.defaultChecked,
    });

    this.onChange = options.onChange;
  }

  get isControlled() {
    return this.state.checked !== undefined;
  }

  toggle() {
    this.setChecked(!this.state.isChecked);
  }

  setChecked(isChecked: boolean) {
    if (this.state.disabled) {
      return;
    }

    if (this.isControlled) {
      this.onChange?.(isChecked);
    } else {
      this.setState({
        isChecked,
      });
      this.onChange?.(isChecked);
    }
  }

  setDisabled(isDisabled: boolean) {
    this.setState({
      disabled: isDisabled,
    });
  }

  setSize(size: 'sm' | 'md' | 'lg') {
    this.setState({
      size,
    });
  }
}

useSwitch

이제 SwitchApi 를 Provider 로 넣을 수 있는 SwitchContext 체계를 만들어봅니다.

Provider 를 만들 때 Api(Observable) 에서 정의한 subscribe 를 useApiConnect()를 통해서 연결해줍니다.

/* eslint-disable react/jsx-no-constructed-context-values */
import React, { createContext, useContext, useMemo } from 'react';
import { SwitchApi, SwitchApiOptions, SwitchState } from '../api';
import { useApiConnect } from './useApi';

export interface SwitchContextValue {
  api: SwitchApi;
  state: SwitchState;
}

export const SwitchContext = createContext<SwitchContextValue | undefined>(undefined);

export function useSwitchContext() {
  const context = useContext(SwitchContext);
  if (!context) {
    throw new Error('useSwitchContext must be used within a SwitchProvider');
  }
  return context;
}

interface SwitchProviderProps {
  children: React.ReactNode;
  value: SwitchApiOptions;
}

export function SwitchProvider({ children, value }: SwitchProviderProps) {
  const api = useMemo(() => new SwitchApi(value), [value]);
  const state = useApiConnect<SwitchState>(api);

  const contextValue: SwitchContextValue = {
    api,
    state,
  };

  return <SwitchContext.Provider value={contextValue}>{children}</SwitchContext.Provider>;
}

useApiConntect

Api에 정의된 subscribe 를 연결하고 다시 렌더링 하게 해줍니다.

import { useEffect, useState } from 'react';
import { Observable } from '../api/Observable';

export function useApiConnect<T>(api: Observable<T>) {
  const [state, setState] = useState(api.getState());

  useEffect(() => {
    const observer = (newState: any) => {
      setState(newState);
    };

    api.subscribe(observer);

    setState(api.getState());

    return () => {
      api.unsubscribe(observer);
    };
  }, [api]);

  return state;
}

Switch Headless Component

지금가지 했던 것들을 모두 컴포넌트에 적용해줍니다.

import React from 'react';
import { sweetch } from '../../styled-system/recipes';
import { css, cx } from '../../styled-system/css';
import { SwitchContextValue, SwitchProvider, useSwitchContext } from '../hooks';
import { ColorPalette } from '../../styled-system/tokens';
import { SwitchSizeType } from '../api';

interface SwitchProps extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> {
  colorPalette?: ColorPalette;
  size?: SwitchSizeType;
  disabled?: boolean;
  defaultChecked?: boolean;
  checked?: boolean;
  onChange?: (checked: boolean) => void;
}

export function Switch({
  children,
  className,
  size = 'md',
  colorPalette = 'gray',
  disabled = false,
  defaultChecked,
  checked,
  onChange,
  ...props
}: SwitchProps) {
  const classes = sweetch({
    size,
    disabled,
  });

  return (
    <SwitchProvider value={{ checked, size, disabled, colorPalette, defaultChecked, onChange }}>
      <div
        {...props}
        className={cx(
          classes.root,
          className,
          css({
            colorPalette,
          }),
        )}
        tabIndex={-1}
      >
        {children}
      </div>
    </SwitchProvider>
  );
}

interface SwitchTrackProps extends React.HTMLAttributes<HTMLDivElement> {}

Switch.Track = function SwitchTrack({ className, ...props }: SwitchTrackProps) {
  const { api, state } = useSwitchContext();
  const classes = sweetch({
    disabled: state.disabled,
    checked: state.isChecked,
    size: state.size,
  });

  const handleClick = () => {
    if (state.disabled) {
      return;
    }
    api.toggle();
  };

  return (
    <div
      {...props}
      className={cx(
        classes.track,
        className,
        css({
          colorPalette: state.colorPalette,
        }),
      )}
      onClick={handleClick}
    />
  );
};

interface SwitchThumbProps extends React.HTMLAttributes<HTMLDivElement> {}

Switch.Thumb = function SwitchThumb({ className, ...props }: SwitchThumbProps) {
  const { state } = useSwitchContext();
  const classes = sweetch({
    disabled: state.disabled,
    checked: state.isChecked,
    size: state.size,
  });

  return (
    <div
      {...props}
      className={cx(
        classes.thumb,
        className,
        css({
          colorPalette: state.colorPalette,
        }),
      )}
    />
  );
};

interface SwitchContextProps {
  children: (props: SwitchContextValue) => React.ReactNode | React.ReactNode;
}

Switch.Context = function SwitchContext({ children }: SwitchContextProps) {
  const apiContext = useSwitchContext();

  if (typeof children === 'function') {
    return children(apiContext);
  }

  return children;
};

사용하기

간단하게 이런 패턴으로 사용할 수 있어요.

function MyComponent() {
  return (
    <Switch size="lg" colorPalette="blue" defaultChecked>
      <Switch.Track>
        <Switch.Thumb />
      </Switch.Track>
    </Switch>
  );
}

이렇게도 사용할 수 있어요.

function MyComponent() {
  return (
    <Switch size="md" colorPalette="green">
      <Switch.Context>
        {({ state, api }) => (
          <>
            <Switch.Track>
              <Switch.Thumb />
            </Switch.Track>
            <button onClick={() => api.toggle()}>토글</button>
            <p>Switch 상태: {state.isChecked ? '켜짐' : '꺼짐'}</p>
          </>
        )}
      </Switch.Context>
    </Switch>
  );
}

Pandacss 로 SlotRecipe 만들기

스타일링은 대략 이렇게 구조 맞춰서 하면 됩니다.

import { defineSlotRecipe } from '@pandacss/dev';

export const sweetch = defineSlotRecipe({
  className: 'switch',
  description: 'Switch',
  slots: ['root', 'track', 'thumb'],
  jsx: ['Switch', 'Switch.Track', 'Switch.Thumb'],
  base: {
    root: {
      position: 'relative',
      display: 'inline-flex',
      alignItems: 'center',
      userSelect: 'none',
      justifyContent: 'flex-start',
      width: '16',
      height: '10',
    },
    track: {
      width: 'full',
      height: 'full',
      borderRadius: 'full',
      bgColor: 'gray.400',
      transition: 'all 0.2s',
      display: 'flex',
      alignItems: 'center',
    },
    thumb: {
      width: '8',
      height: '8',
      borderRadius: 'full',
      bgColor: 'white',
      boxShadow: 'md',
      transition: 'all 0.2s',
      transform: 'translateX(calc(100% - var(--switch-offset-x, 4px)))',
    },
  },
  variants: {
    checked: {
      false: {
        track: {
          bgColor: 'colorPalette.600',
        },
        thumb: {
          transform: 'translateX(4px)',
        },
      },
    },
    disabled: {
      true: {
        root: {
          cursor: 'not-allowed',
          opacity: 0.5,
        },
      },
    },
    size: {
      xs: {
        root: {
          width: '10',
          height: '6',
        },
        track: {
          width: 'full',
          height: 'full',
        },
        thumb: {
          width: '4',
          height: '4',
          '--switch-offset-x': '-4px',
        },
      },
      sm: {
        root: {
          width: '12',
          height: '8',
        },
        track: {
          width: 'full',
          height: 'full',
        },
        thumb: {
          width: '6',
          height: '6',
        },
      },
      md: {
        root: {
          width: '16',
          height: '10',
        },
        track: {
          width: 'full',
          height: 'full',
        },
        thumb: {
          width: '8',
          height: '8',
        },
      },
      lg: {
        root: {
          width: '20',
          height: '12',
        },
        track: {
          width: 'full',
          height: 'full',
        },
        thumb: {
          width: '10',
          height: '10',
        },
      },
    },
  },
  defaultVariants: {
    size: 'md',
    checked: false,
    disabled: false,
  },
});

마무리

각각의 영역을 모두 분리해서 간단한 컴포넌트를 만들었습니다.
이거 잘 분리하면 다른 곳에도 써먹을 수 있겠죠?

profile
행복개발자

0개의 댓글