feat: 공통 컴포넌트 추가 생성
This commit is contained in:
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal 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;
|
||||
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BreadcrumbsProps } from './Breadcrumbs';
|
||||
import InternalBreadcrumbs from './Breadcrumbs';
|
||||
|
||||
export type { BreadcrumbsProps };
|
||||
export const Breadcrumbs = InternalBreadcrumbs;
|
||||
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const DescriptionsItem = (props: DescriptionsItemProps) => {
|
||||
</div>
|
||||
<div
|
||||
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,
|
||||
isFirstRow &&
|
||||
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'
|
||||
|
||||
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal file
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export * from './Calendar';
|
||||
export * from './ChevronLeft';
|
||||
export * from './ChevronRight';
|
||||
export * from './LoadingSpinner';
|
||||
export * from './OverlayArrow';
|
||||
|
||||
|
||||
102
web-app/app/shared/components/radio/Radio.tsx
Normal file
102
web-app/app/shared/components/radio/Radio.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal file
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
web-app/app/shared/components/switch/Switch.tsx
Normal file
59
web-app/app/shared/components/switch/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
web-app/app/shared/components/tab/Tab.tsx
Normal file
55
web-app/app/shared/components/tab/Tab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal file
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal 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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
1
web-app/app/shared/components/textarea/index.ts
Normal file
1
web-app/app/shared/components/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './TextArea';
|
||||
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal file
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
web-app/app/shared/components/tooltip/index.ts
Normal file
1
web-app/app/shared/components/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Tooltip';
|
||||
Reference in New Issue
Block a user