Switch 컴포넌트로 Headless 컴포넌트를 만들어봅니다.
몇가지 기능이 빠질 수도 있지만 ...
간단한 상태를 저장할 곳을 하나 만들어줍니다.
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;
}
}
상태를 실제로 관리하는 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,
});
}
}
이제 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>;
}
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;
}
지금가지 했던 것들을 모두 컴포넌트에 적용해줍니다.
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>
);
}
스타일링은 대략 이렇게 구조 맞춰서 하면 됩니다.
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,
},
});
각각의 영역을 모두 분리해서 간단한 컴포넌트를 만들었습니다.
이거 잘 분리하면 다른 곳에도 써먹을 수 있겠죠?