feat: 공통 컴포넌트 추가 생성

This commit is contained in:
2026-04-13 15:27:25 +09:00
parent 01c3590682
commit 52f8c30b2e
14 changed files with 500 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react';
import { Breadcrumb as AriaBreadcrumb, Breadcrumbs as AriaBreadcrumbs } from 'react-aria-components';
export interface BreadcrumbsProps {
items: string[];
}
const Breadcrumbs: FC<BreadcrumbsProps> = (props) => {
const { items } = props;
return (
<nav aria-label="Breadcrumb">
<AriaBreadcrumbs className="flex items-center gap-2.5">
<AriaBreadcrumb aria-label="홈">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="19"
height="18"
viewBox="0 0 19 18"
fill="none"
>
<path
d="M6.75 14.4545H4.25V7.83333L9.25 4L14.25 7.83333V14.4545H11.75"
stroke="#6C7789"
strokeLinecap="square"
/>
<path d="M10.8381 12.9615V10.3633H7.65625V12.9615" stroke="#6C7789" />
</svg>
</AriaBreadcrumb>
{items.map((item, index) => (
<AriaBreadcrumb
className="flex items-center gap-2.5 text-xs text-dabeeo-black-47 data-current:text-dabeeo-black-22"
key={`breadcrumb-${index}-${item}`}
>
<>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="6"
height="8"
viewBox="0 0 6 8"
fill="none"
>
<path
d="M2.70722 4.00064L0.25 1.54349L1.4556 0.337891L5.12572 4.00064L1.45572 7.66347L0.250117 6.45786L2.70722 4.00064Z"
fill="#6C7789"
/>
</svg>
{item}
</>
</AriaBreadcrumb>
))}
</AriaBreadcrumbs>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -0,0 +1,5 @@
import type { BreadcrumbsProps } from './Breadcrumbs';
import InternalBreadcrumbs from './Breadcrumbs';
export type { BreadcrumbsProps };
export const Breadcrumbs = InternalBreadcrumbs;

View File

@@ -0,0 +1,66 @@
import type { CheckboxProps as AriaCheckboxProps } from 'react-aria-components';
import { Checkbox as AriaCheckbox, composeRenderProps } from 'react-aria-components';
import { tv } from 'tailwind-variants';
const checkbox = tv({
base: 'inline-flex items-center gap-2 text-sm font-medium leading-7',
variants: {
isDisabled: {
true: 'text-dabeeo-gray-be',
},
},
});
const box = tv({
base: 'w-4 h-4 box-border shrink-0 flex items-center justify-center text-white border border-dabeeo-gray-be transition',
variants: {
isSelected: {
true: 'bg-primary border-primary',
},
isDisabled: {
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
compoundVariants: [],
});
export const Checkbox = (props: AriaCheckboxProps) => {
const { className, children, ...restProps } = props;
return (
<AriaCheckbox
className={composeRenderProps(className, (className, renderProps) => checkbox({ ...renderProps, className }))}
{...restProps}
>
{composeRenderProps(children, (children, { isSelected, isIndeterminate, ...renderProps }) => (
<>
<div className={box({ isSelected: isSelected || isIndeterminate, ...renderProps })}>
{isIndeterminate ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
</svg>
) : isSelected ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7.00893L7.85714 10.625L13 5" stroke="currentColor" strokeWidth="2" />
</svg>
) : null}
</div>
{children}
</>
))}
</AriaCheckbox>
);
};

View File

@@ -26,7 +26,7 @@ export const DescriptionsItem = (props: DescriptionsItemProps) => {
</div> </div>
<div <div
className={clsx( className={clsx(
'relative flex flex-1 items-center pl-4 pr-3 py-2 text-sm font-medium text-kc-black-22 overflow-x-auto after:content-[\'\'] after:absolute after:left-0 after:right-0 after:bottom-0 after:h-px after:bg-primary-tertiary01', 'relative flex flex-1 items-center pl-4 pr-3 py-2 text-sm font-medium text-dabeeo-black-22 overflow-x-auto after:content-[\'\'] after:absolute after:left-0 after:right-0 after:bottom-0 after:h-px after:bg-primary-tertiary01',
contentClassName, contentClassName,
isFirstRow && isFirstRow &&
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01' 'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'

View File

@@ -0,0 +1,9 @@
import { SVGProps } from 'react';
export function OverlayArrowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M4.5 7L0 0H9L4.5 7Z" />
</svg>
);
}

View File

@@ -2,4 +2,5 @@ export * from './Calendar';
export * from './ChevronLeft'; export * from './ChevronLeft';
export * from './ChevronRight'; export * from './ChevronRight';
export * from './LoadingSpinner'; export * from './LoadingSpinner';
export * from './OverlayArrow';

View File

@@ -0,0 +1,102 @@
import { type ReactNode, useId } from 'react';
import { tv } from 'tailwind-variants';
export interface RadioProps {
name: string;
value: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (value: string) => void;
disabled?: boolean;
readOnly?: boolean;
children?: ReactNode;
className?: string;
}
const radioStyles = tv({
base: [
'shrink-0 w-4 h-4 min-w-4 min-h-4 box-border rounded-full',
'border-2 border-dabeeo-gray-be bg-white',
'flex items-center justify-center',
'transition-all duration-200',
'peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-primary',
],
variants: {
isSelected: {
true: 'border-primary bg-primary',
},
isDisabled: {
true: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
},
isReadOnly: {
true: '',
},
},
compoundVariants: [
{
isSelected: true,
isDisabled: true,
className: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
},
],
});
const innerCircleStyles = tv({
base: 'w-2 h-2 rounded-full transition-all duration-200',
variants: {
isSelected: {
true: 'bg-white',
false: 'bg-transparent',
},
isDisabled: {
true: 'bg-dabeeo-gray-eb',
},
},
compoundVariants: [
{
isSelected: true,
isDisabled: true,
className: 'bg-dabeeo-gray-eb',
},
],
});
const labelStyles = tv({
base: 'text-dabeeo-black-22 text-sm font-medium inline-flex items-center',
variants: {
isDisabled: {
true: 'text-dabeeo-gray-be',
},
},
});
export const Radio = (props: RadioProps) => {
const { name, value, checked, defaultChecked, onChange, disabled, readOnly, children, className } = props;
const id = useId();
const isInteractive = !disabled && !readOnly;
return (
<label
htmlFor={id}
className={`inline-flex h-9 items-center gap-2.5 ${isInteractive ? 'cursor-pointer' : 'cursor-not-allowed'} ${className ?? ''}`}
>
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
defaultChecked={defaultChecked}
onChange={(e) => isInteractive && onChange?.(e.target.value)}
disabled={disabled}
readOnly={readOnly}
className="sr-only peer"
/>
<span className={radioStyles({ isSelected: checked, isDisabled: disabled, isReadOnly: readOnly })}>
<span className={innerCircleStyles({ isSelected: checked, isDisabled: disabled })} />
</span>
{children && <span className={labelStyles({ isDisabled: disabled })}>{children}</span>}
</label>
);
};

View File

@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { useId } from 'react';
import type { RadioGroupProps as AriaRadioGroupProps, RadioProps as AriaRadioProps } from 'react-aria-components';
import { Radio as AriaRadio, RadioGroup as AriaRadioGroup, composeRenderProps } from 'react-aria-components';
import clsx from 'clsx';
import { tv } from 'tailwind-variants';
export type RadioOption = {
value: string;
label: ReactNode;
isDisabled?: boolean;
};
export interface RadioGroupProps extends AriaRadioGroupProps {
items: RadioOption[];
}
export const RadioGroup = (props: RadioGroupProps) => {
const radioGroupId = useId();
const { items, className, value, ...restProps } = props;
return (
<AriaRadioGroup
className={clsx('flex gap-4 data-[orientation=vertical]:flex-col data-[orientation=vertical]:gap-3', className)}
value={value === '' ? null : value}
{...restProps}
>
{items.map((item) => {
return (
<Radio key={`radio_group-${radioGroupId}-${item.value}`} value={item.value} isDisabled={item.isDisabled}>
{item.label}
</Radio>
);
})}
</AriaRadioGroup>
);
};
interface RadioProps extends AriaRadioProps {}
const radioStyles = tv({
base: 'shrink-0 w-4 h-4 box-border rounded-full border-[1.5px] border-dabeeo-gray-be bg-white transition-all',
variants: {
isSelected: {
true: 'border-4 border-primary ',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
isInvalid: {
true: 'border-dabeeo-red',
},
isDisabled: {
true: 'border-dabeeo-gray-be',
},
},
compoundVariants: [{ isDisabled: true, isSelected: false, className: 'bg-dabeeo-gray-eb' }],
});
export const Radio = (props: RadioProps) => {
const { className, ...restProps } = props;
return (
<AriaRadio
className={clsx(
'inline-flex items-center gap-2.5 text-dabeeo-black-22 text-sm font-medium data-disabled:text-dabeeo-gray-be',
className
)}
{...restProps}
>
{composeRenderProps(props.children, (children, renderProps) => (
<>
<div className={radioStyles(renderProps)} />
{children}
</>
))}
</AriaRadio>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Switch as AriaSwitch, SwitchProps as AriaSwitchProps } from 'react-aria-components';
import clsx from 'clsx';
import { tv } from 'tailwind-variants';
export interface SwitchProps extends Omit<AriaSwitchProps, 'children'> {
children?: React.ReactNode;
}
const track = tv({
base: 'flex h-4.5 w-8 box-border px-1 items-center rounded-full transition duration-200 ease-in-out',
variants: {
isSelected: {
false: 'bg-dabeeo-gray-99',
true: 'bg-dabeeo-green-main',
},
isDisabled: {
true: 'bg-dabeeo-gray-eb',
},
isReadOnly: {
true: 'opacity-50',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
});
const handle = tv({
base: 'h-3 w-3 transform rounded-full outline outline-1 -outline-offset-1 outline-transparent shadow-[0_1px_3px_0_rgba(5,48,48,0.35)] transition duration-200 ease-in-out',
variants: {
isSelected: {
false: 'translate-x-0 bg-white',
true: 'translate-x-[11px] bg-white',
},
},
});
export function Switch({ children, ...props }: SwitchProps) {
return (
<AriaSwitch
{...props}
className={clsx(
props.className,
'relative flex gap-2 items-center text-dabeeo-black-34 text-sm transition [-webkit-tap-highlight-color:transparent] cursor-pointer'
)}
>
{(renderProps) => (
<>
<div className={track(renderProps)}>
<span className={handle(renderProps)} />
</div>
{children}
</>
)}
</AriaSwitch>
);
}

View File

@@ -0,0 +1,55 @@
import type { TabsProps } from 'react-aria-components';
import { Tab as AriaTab, TabList, TabPanel, TabPanels, Tabs } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
type TabItem = {
key: string;
label: React.ReactNode;
children: React.ReactNode;
disabled?: boolean;
};
// type TabProps = {
// items: TabItem[];
// activeKey?: string;
// onClick?: (key: string) => void;
// className?: string;
// };
/**
* @desc 탭 컴포넌트
* @param items 탭 아이템 목록
* @param activeKey 현재 활성화된 탭의 키
* @param onClick 탭 변경 이벤트
* @param className 추가 클래스명
*/
export interface TabProps extends Omit<TabsProps, 'className' | 'children'> {
className?: string;
items: TabItem[];
tabPanelClassName?: string;
tabPanelsClassName?: string;
}
export const Tab = ({ items, className, tabPanelClassName, tabPanelsClassName, ...restProps }: TabProps) => {
return (
<Tabs className={twMerge('flex flex-col gap-4', className)} {...restProps}>
<TabList className="flex items-center text-lg font-medium cursor-pointer" items={items}>
{(item) => (
<AriaTab
id={item.key}
className="relative px-7.5 text-dabeeo-black-47 data-[selected=true]:text-primary first:pl-1.5 data-[selected=true]:font-bold after:absolute after:right-0 after:top-1/2 after:-translate-y-1/2 after:h-[18px] after:w-px after:bg-gray-300 last:after:hidden outline-none data-focus-visible:ring-2 data-focus-visible:ring-offset-2 data-focus-visible:ring-primary"
>
{item.label}
</AriaTab>
)}
</TabList>
<TabPanels className={tabPanelsClassName} items={items}>
{(item) => (
<TabPanel id={item.key} className={tabPanelClassName}>
{item.children}
</TabPanel>
)}
</TabPanels>
</Tabs>
);
};

View File

@@ -0,0 +1,21 @@
import { TextArea as AriaTextArea, TextAreaProps as AriaTextAreaProps } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
interface TextAreaProps extends Omit<AriaTextAreaProps, 'className'> {
className?: string;
}
export const TextArea = (props: TextAreaProps) => {
const { className, ...restProps } = props;
return (
<AriaTextArea
{...restProps}
className={twMerge(
'block px-3 py-2.5 text-sm text-dabeeo-black-34 border border-dabeeo-gray-be data-[hovered=true]:border-dabeeo-black-34 data-[focused=true]:text-dabeeo-black-34 data-focused:border-dabeeo-black-34 outline-0 resize-none',
'read-only:bg-dabeeo-yellow-secondary read-only:data-[hovered=true]:border-dabeeo-gray-be read-only:data-[focused=true]:border-dabeeo-gray-be',
className
)}
/>
);
};

View File

@@ -0,0 +1 @@
export * from './TextArea';

View File

@@ -0,0 +1,44 @@
import React from 'react';
import {
Tooltip as AriaTooltip,
TooltipProps as AriaTooltipProps,
OverlayArrow,
composeRenderProps,
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
import { OverlayArrowIcon } from '../icons';
const style = tv({
base: 'group/tooltip px-2 py-1.5 max-w-[200px] drop-shadow-modal rounded text-sm',
variants: {
bgColor: {
white: 'bg-white [&_svg]:fill-white',
tertiary: 'bg-primary-tertiary [&_svg]:fill-primary-tertiary',
},
},
});
export interface TooltipProps extends Omit<AriaTooltipProps, 'children'> {
bgColor?: 'white' | 'tertiary';
children: React.ReactNode;
}
export function Tooltip({ children, ...props }: TooltipProps) {
const { className, bgColor = 'white', offset = 10, ...restProps } = props;
return (
<AriaTooltip
offset={offset}
{...restProps}
className={composeRenderProps(className, (className, renderProps) =>
style({ ...renderProps, className, bgColor })
)}
>
<OverlayArrow>
<OverlayArrowIcon className="block group-data-[placement=bottom]/tooltip:rotate-180 group-data-[placement=left]/tooltip:-rotate-90 group-data-[placement=right]/tooltip:rotate-90" />
</OverlayArrow>
<span className="block max-h-[200px] overflow-y-auto whitespace-pre-wrap">{children}</span>
</AriaTooltip>
);
}

View File

@@ -0,0 +1 @@
export * from './Tooltip';