feat: 컴포넌트 추가, 스타일 수정
This commit is contained in:
@@ -19,21 +19,21 @@ const button = tv({
|
||||
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
|
||||
},
|
||||
color: {
|
||||
primary: 'text-white bg-primary border-primary disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-primary',
|
||||
primary: 'text-white bg-primary border-primary disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-primary',
|
||||
light:
|
||||
'text-primary border-primary bg-primary-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-primary',
|
||||
'text-primary border-primary bg-primary-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-primary',
|
||||
green:
|
||||
'text-white bg-kc-green-main border-kc-green-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-green-main',
|
||||
'text-white bg-dabeeo-green-main border-dabeeo-green-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||
lightGreen:
|
||||
'text-kc-green-main border-kc-green-main bg-kc-green-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-green-main',
|
||||
'text-dabeeo-green-main border-dabeeo-green-main bg-dabeeo-green-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||
black:
|
||||
'text-white bg-kc-black-34 border-kc-black-34 disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-black-34',
|
||||
gray: 'text-kc-black-34 bg-kc-gray-eb border-kc-black-34 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-black-34',
|
||||
'text-white bg-dabeeo-black-34 border-dabeeo-black-34 disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||
gray: 'text-dabeeo-black-34 bg-dabeeo-gray-eb border-dabeeo-black-34 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||
orange:
|
||||
'text-kc-orange-main bg-kc-orange-tertiary01 border-kc-orange-main disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-orange-main',
|
||||
navy: 'text-white bg-kc-navy-main border-kc-navy-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-navy-main',
|
||||
'text-dabeeo-orange-main bg-dabeeo-orange-tertiary01 border-dabeeo-orange-main disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-orange-main',
|
||||
navy: 'text-white bg-dabeeo-navy-main border-dabeeo-navy-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||
lightNavy:
|
||||
'text-kc-navy-main border-kc-navy-main bg-kc-navy-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-navy-main',
|
||||
'text-dabeeo-navy-main border-dabeeo-navy-main bg-dabeeo-navy-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-not-allowed',
|
||||
@@ -62,48 +62,48 @@ const button = tv({
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-green-tertiary hover:border-kc-green-tertiary active:bg-kc-green-secondary active:border-kc-green-secondary',
|
||||
'hover:bg-dabeeo-green-tertiary hover:border-dabeeo-green-tertiary active:bg-dabeeo-green-secondary active:border-dabeeo-green-secondary',
|
||||
},
|
||||
{
|
||||
color: 'lightGreen',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-green-tertiary01 active:text-kc-green-secondary active:bg-kc-green-tertiary01 active:border-kc-green-secondary',
|
||||
'hover:bg-dabeeo-green-tertiary01 active:text-dabeeo-green-secondary active:bg-dabeeo-green-tertiary01 active:border-dabeeo-green-secondary',
|
||||
},
|
||||
{
|
||||
color: 'black',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className: 'hover:bg-kc-black-47 hover:border-kc-black-47 active:bg-kc-black-22 active:border-kc-black-22',
|
||||
className: 'hover:bg-dabeeo-black-47 hover:border-dabeeo-black-47 active:bg-dabeeo-black-22 active:border-dabeeo-black-22',
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-gray-da hover:border-kc-black-47 active:text-kc-black-22 active:bg-kc-gray-da active:border-kc-black-22',
|
||||
'hover:bg-dabeeo-gray-da hover:border-dabeeo-black-47 active:text-dabeeo-black-22 active:bg-dabeeo-gray-da active:border-dabeeo-black-22',
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-orange-tertiary02 active:text-kc-orange-secondary active:bg-kc-orange-tertiary02 active:border-kc-orange-secondary',
|
||||
'hover:bg-dabeeo-orange-tertiary02 active:text-dabeeo-orange-secondary active:bg-dabeeo-orange-tertiary02 active:border-dabeeo-orange-secondary',
|
||||
},
|
||||
{
|
||||
color: 'navy',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:border-kc-navy-tertiary hover:bg-kc-navy-tertiary active:bg-kc-navy-secondary active:border-kc-navy-secondary',
|
||||
'hover:border-dabeeo-navy-tertiary hover:bg-dabeeo-navy-tertiary active:bg-dabeeo-navy-secondary active:border-dabeeo-navy-secondary',
|
||||
},
|
||||
{
|
||||
color: 'lightNavy',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-navy-tertiary01 active:text-kc-navy-secondary active:bg-kc-navy-tertiary01 active:border-kc-navy-secondary',
|
||||
'hover:bg-dabeeo-navy-tertiary01 active:text-dabeeo-navy-secondary active:bg-dabeeo-navy-tertiary01 active:border-dabeeo-navy-secondary',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -24,13 +24,13 @@ const cellStyles = tv({
|
||||
false: '',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default text-kc-gray-be',
|
||||
true: 'cursor-default text-dabeeo-gray-be',
|
||||
},
|
||||
isUnavailable: {
|
||||
true: 'cursor-default text-kc-gray-be line-through',
|
||||
true: 'cursor-default text-dabeeo-gray-be line-through',
|
||||
},
|
||||
isOutsideMonth: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isSunday: {
|
||||
true: 'text-[#e48686]',
|
||||
@@ -143,8 +143,8 @@ const navButton = tv({
|
||||
base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'cursor-default text-kc-gray-be',
|
||||
false: 'cursor-pointer text-kc-black-34 hover:bg-kc-gray-eb',
|
||||
true: 'cursor-default text-dabeeo-gray-be',
|
||||
false: 'cursor-pointer text-dabeeo-black-34 hover:bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -161,7 +161,7 @@ export function CalendarHeader() {
|
||||
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</AriaButton>
|
||||
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-kc-black-34" />
|
||||
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-dabeeo-black-34" />
|
||||
<AriaButton slot="next" className={(renderProps) => navButton(renderProps)}>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</AriaButton>
|
||||
@@ -173,7 +173,7 @@ export function CalendarGridHeader() {
|
||||
return (
|
||||
<AriaCalendarGridHeader>
|
||||
{(day) => (
|
||||
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-kc-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
|
||||
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-dabeeo-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
|
||||
{day}
|
||||
</CalendarHeaderCell>
|
||||
)}
|
||||
|
||||
@@ -21,10 +21,10 @@ const rangeCell = tv({
|
||||
cap: 'bg-primary font-bold text-white',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isOutsideMonth: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isSunday: {
|
||||
true: 'text-[#e48686]',
|
||||
|
||||
@@ -8,16 +8,16 @@ import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||
import { CalendarIcon } from '../icons';
|
||||
|
||||
const trigger = tv({
|
||||
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
|
||||
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||
variants: {
|
||||
isHovered: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
||||
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -51,21 +51,21 @@ export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, cl
|
||||
>
|
||||
<Group>
|
||||
<Button className={(renderProps) => trigger(renderProps)}>
|
||||
<span className={`flex-1 px-3 text-sm ${value ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className={`flex-1 px-3 text-sm ${value ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value ? dayjs(value).format('YYYY.MM.DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</Group>
|
||||
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<OverlayArrow className="group/arrow">
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
>
|
||||
<path d="M0 0 L6 8 L12 0" />
|
||||
</svg>
|
||||
|
||||
@@ -16,16 +16,16 @@ import { RangeCalendar } from '../calendar/RangeCalendar';
|
||||
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||
|
||||
const trigger = tv({
|
||||
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
|
||||
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||
variants: {
|
||||
isHovered: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
||||
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -81,25 +81,25 @@ export function DateRangePicker({
|
||||
>
|
||||
<Group>
|
||||
<Button className={(renderProps) => trigger(renderProps)}>
|
||||
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value?.start ? dayjs(value.start).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="text-sm text-kc-gray-99">~</span>
|
||||
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className="text-sm text-dabeeo-gray-99">~</span>
|
||||
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value?.end ? dayjs(value.end).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</Group>
|
||||
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<OverlayArrow className="group/arrow">
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
>
|
||||
<path d="M0 0 L6 8 L12 0" />
|
||||
</svg>
|
||||
|
||||
70
web-app/app/shared/components/descriptions/Descriptions.tsx
Normal file
70
web-app/app/shared/components/descriptions/Descriptions.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { DescriptionsItem, DescriptionsItemProps } from './items/DescriptionsItem';
|
||||
|
||||
export type DescriotionItemType = {
|
||||
items: (DescriptionsItemProps & { key?: string })[];
|
||||
column?: 1 | 2 | 3 | 4;
|
||||
titleWidth?: number;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export const Descriptions = (props: DescriotionItemType) => {
|
||||
const { items, column = 1, titleWidth, className, titleClassName, contentClassName } = props;
|
||||
|
||||
const columnClassName =
|
||||
{
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}[column] || 'grid-cols-1';
|
||||
|
||||
const getFirstRowItemIndices = () => {
|
||||
const firstRowItemIndices: number[] = [];
|
||||
let availableColumns = column;
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
if (availableColumns <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
const itemSpan = item.span === 'filled' ? column : (item.span ?? 1);
|
||||
const effectiveSpan = Math.min(typeof itemSpan === 'number' ? itemSpan : column, column);
|
||||
|
||||
if (effectiveSpan > availableColumns) {
|
||||
break;
|
||||
}
|
||||
|
||||
firstRowItemIndices.push(index);
|
||||
availableColumns -= effectiveSpan;
|
||||
|
||||
if (availableColumns === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return firstRowItemIndices;
|
||||
};
|
||||
|
||||
const firstRowItemIndices = getFirstRowItemIndices();
|
||||
|
||||
return (
|
||||
<div className={`grid w-full gap-0 ${columnClassName || ''} ${className || ''}`}>
|
||||
{items.map((item, index) => {
|
||||
const { key, ...itemProps } = item;
|
||||
return (
|
||||
<DescriptionsItem
|
||||
key={key || index}
|
||||
{...itemProps}
|
||||
isFirstRow={firstRowItemIndices.includes(index)}
|
||||
titleWidth={titleWidth}
|
||||
titleClassName={titleClassName}
|
||||
contentClassName={contentClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type DescriptionsItemProps = {
|
||||
title: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
span?: number | 'filled';
|
||||
isFirstRow?: boolean;
|
||||
titleWidth?: number;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export const DescriptionsItem = (props: DescriptionsItemProps) => {
|
||||
const { title, content, span, isFirstRow = false, titleWidth, titleClassName, contentClassName } = props;
|
||||
|
||||
const gridColumnStyle =
|
||||
span === 'filled' ? { gridColumn: '1 / -1' } : span && span > 0 ? { gridColumn: `span ${span}` } : {};
|
||||
|
||||
return (
|
||||
<div className="flex" style={gridColumnStyle}>
|
||||
<div
|
||||
className={`flex w-25 min-h-10 items-center px-4 bg-primary-tertiary01 text-primary-secondary text-sm font-semibold border-b border-white ${titleClassName || ''}`}
|
||||
style={{ width: titleWidth }}
|
||||
>
|
||||
{title}
|
||||
</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',
|
||||
contentClassName,
|
||||
isFirstRow &&
|
||||
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,18 +16,18 @@ const style = tv({
|
||||
'flex',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'text-kc-black-34',
|
||||
'text-dabeeo-black-34',
|
||||
'leading-[18px]',
|
||||
'border',
|
||||
'border-kc-gray-be',
|
||||
'has-data-focused:border-kc-black-34',
|
||||
'has-data-hovered:border-kc-black-34',
|
||||
'has-data-disabled:bg-kc-gray-eb',
|
||||
'has-data-disabled:text-kc-gray-99',
|
||||
'has-data-disabled:border-kc-gray-be',
|
||||
'has-data-invalid:border-kc-red',
|
||||
'has-data-invalid:has-data-focused:border-kc-red',
|
||||
'has-data-invalid:has-data-hovered:border-kc-red',
|
||||
'border-dabeeo-gray-be',
|
||||
'has-data-focused:border-dabeeo-black-34',
|
||||
'has-data-hovered:border-dabeeo-black-34',
|
||||
'has-data-disabled:bg-dabeeo-gray-eb',
|
||||
'has-data-disabled:text-dabeeo-gray-99',
|
||||
'has-data-disabled:border-dabeeo-gray-be',
|
||||
'has-data-invalid:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-focused:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-hovered:border-dabeeo-red',
|
||||
'w-full',
|
||||
'has-[[type=number]]:leading-6',
|
||||
],
|
||||
@@ -36,7 +36,7 @@ const style = tv({
|
||||
variants: {
|
||||
isReadOnly: {
|
||||
true: {
|
||||
base: 'bg-kc-yellow-secondary has-data-hovered:border-kc-gray-be has-data-focused:border-kc-gray-be',
|
||||
base: 'bg-dabeeo-yellow-secondary has-data-hovered:border-dabeeo-gray-be has-data-focused:border-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal file
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '../button/Button';
|
||||
import type { ModalRootProps } from './Modal';
|
||||
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen?: ModalRootProps['isOpen'];
|
||||
onOpenChange?: ModalRootProps['onOpenChange'];
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const AlertModal = (props: AlertModalProps) => {
|
||||
const { isOpen, onOpenChange, title, ...contentProps } = props;
|
||||
|
||||
return (
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '알림'}>
|
||||
{({ close }) => <AlertModalContent close={close} title={title} {...contentProps} />}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
function AlertModalContent({
|
||||
close,
|
||||
title,
|
||||
content,
|
||||
confirmText = '확인',
|
||||
onConfirm,
|
||||
}: {
|
||||
close: () => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <ModalHeader>{title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" autoFocus onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal file
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Button } from '../button/Button';
|
||||
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal = (props: ConfirmModalProps) => {
|
||||
const { isOpen, onOpenChange, title, ...contentProps } = props;
|
||||
|
||||
return (
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '확인'}>
|
||||
{({ close }) => <ConfirmModalContent close={close} title={title} {...contentProps} />}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
function ConfirmModalContent({
|
||||
close,
|
||||
title,
|
||||
content,
|
||||
confirmText = '확인',
|
||||
cancelText = '취소',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
close: () => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
startTransition(async () => {
|
||||
await onConfirm();
|
||||
close();
|
||||
});
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <ModalHeader>{title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" isDisabled={isPending} onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button color="primary" size="large" isPending={isPending} onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
web-app/app/shared/components/modal/Modal.tsx
Normal file
229
web-app/app/shared/components/modal/Modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
import { use, useSyncExternalStore, useTransition } from 'react';
|
||||
import type { DialogProps, ModalOverlayProps } from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
OverlayTriggerStateContext,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '../button/Button';
|
||||
|
||||
interface ModalRootProps
|
||||
extends DialogProps,
|
||||
Pick<ModalOverlayProps, 'isOpen' | 'onOpenChange' | 'isKeyboardDismissDisabled' | 'isDismissable'> {
|
||||
className?: string;
|
||||
}
|
||||
const ModalRoot = (props: ModalRootProps) => {
|
||||
const { isOpen, onOpenChange, isKeyboardDismissDisabled, isDismissable, className, ...dialogProps } = props;
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
||||
isDismissable={isDismissable}
|
||||
className="absolute top-0 left-0 w-full h-full bg-black/70 z-100"
|
||||
>
|
||||
<Modal className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 supports-[translate:round(up,-50%,1px)]:translate-[round(up,-50%,1px)] min-w-75 bg-white outline-none drop-shadow-modal">
|
||||
<Dialog className={twMerge('outline-0', className)} {...dialogProps}>
|
||||
{props.children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
hasCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const ModalHeader = (props: ModalHeaderProps) => {
|
||||
const ctx = use(OverlayTriggerStateContext);
|
||||
|
||||
const { hasCloseButton = true } = props;
|
||||
return (
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-1 border-b border-dabeeo-gray-eb">
|
||||
<Heading slot="title" className="text-lg text-primary font-bold">
|
||||
{props.children}
|
||||
</Heading>
|
||||
{hasCloseButton && (
|
||||
<AriaButton
|
||||
onClick={ctx?.close}
|
||||
className="text-dabeeo-black-47 cursor-pointer data-focus-visible:ring-2 ring-offset-2 ring-dabeeo-gray-34"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M18.25 2.4927L17.5074 1.75L10.0021 9.25678L2.49675 1.75L1.7542 2.4927L9.25955 9.99947L1.75 17.5073L2.49255 18.25L9.9979 10.7432L17.5032 18.25L18.2458 17.5073L10.7404 10.0005L18.25 2.4927Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</AriaButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalBodyProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
const ModalBody = (props: ModalBodyProps) => {
|
||||
return <div className={clsx('px-6 py-2.5', props.className)}>{props.children}</div>;
|
||||
};
|
||||
|
||||
interface ModalFooterProps extends React.PropsWithChildren {}
|
||||
const ModalFooter = (props: ModalFooterProps) => {
|
||||
return <div className="px-4 py-2.5 flex justify-center gap-2 border-t border-dabeeo-gray-eb">{props.children}</div>;
|
||||
};
|
||||
|
||||
let _modals: (
|
||||
| {
|
||||
typeof: 'confirm';
|
||||
title?: string;
|
||||
content: string;
|
||||
confirm?: boolean;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
onConfirm: (close: () => void) => void;
|
||||
}
|
||||
| {
|
||||
typeof: 'alert';
|
||||
title?: string;
|
||||
content: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
)[] = [];
|
||||
let listeners: (() => void)[] = [];
|
||||
function subscribe(listener: () => void) {
|
||||
listeners = [...listeners, listener];
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
function getSnapshot() {
|
||||
return _modals;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
type AlertProps = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
const alert = (props: AlertProps) => {
|
||||
_modals = [
|
||||
..._modals,
|
||||
{
|
||||
typeof: 'alert',
|
||||
content: props.content || '',
|
||||
cancelText: props.cancelText || '확인',
|
||||
onCancel: props.onCancel,
|
||||
},
|
||||
];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const confirm = (props: {
|
||||
title?: string;
|
||||
content: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
onConfirm: (close: () => void) => void;
|
||||
}) => {
|
||||
_modals = [
|
||||
..._modals,
|
||||
{
|
||||
typeof: 'confirm',
|
||||
title: props.title,
|
||||
content: props.content,
|
||||
cancelText: props.cancelText,
|
||||
onCancel: props.onCancel,
|
||||
confirmText: props.confirmText,
|
||||
onConfirm: props.onConfirm,
|
||||
},
|
||||
];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
function getServerSnapshot() {
|
||||
return _modals;
|
||||
}
|
||||
|
||||
const ModalRegion = () => {
|
||||
const modals = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
return (
|
||||
<>
|
||||
{modals.map((modal, idx) => (
|
||||
<ModalRoot
|
||||
key={`modal-${idx}`}
|
||||
aria-label={`모달 ${idx}`}
|
||||
className="w-75"
|
||||
isOpen
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
_modals = _modals.filter((f) => f !== modal);
|
||||
emitChange();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{modal.title && <ModalHeader>{modal.title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{modal.content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="gray"
|
||||
size="large"
|
||||
autoFocus
|
||||
isDisabled={isPending}
|
||||
onClick={() => {
|
||||
close();
|
||||
if (typeof modal.onCancel === 'function') {
|
||||
modal.onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modal.cancelText || '취소'}
|
||||
</Button>
|
||||
{modal.typeof === 'confirm' && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
isPending={isPending}
|
||||
onClick={() => {
|
||||
startTransition(modal.onConfirm.bind(null, close));
|
||||
}}
|
||||
>
|
||||
{modal.confirmText || '확인'}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalRoot>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export { alert, confirm, ModalBody, ModalFooter, ModalHeader, ModalRegion, ModalRoot };
|
||||
export type { ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalRootProps };
|
||||
|
||||
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal file
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { modalStore } from './store';
|
||||
|
||||
export const ModalRenderer = () => {
|
||||
const modals = useSyncExternalStore(modalStore.subscribe, modalStore.getSnapshot, modalStore.getServerSnapshot);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modals.map((modal) => {
|
||||
const { Component, props, id, resolve } = modal;
|
||||
return (
|
||||
<Component
|
||||
key={id}
|
||||
{...props}
|
||||
isOpen={true}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
resolve();
|
||||
modalStore.removeModal(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
web-app/app/shared/components/modal/index.ts
Normal file
25
web-app/app/shared/components/modal/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalRegion as ModalRegionPrimitive,
|
||||
ModalRoot,
|
||||
alert,
|
||||
confirm,
|
||||
} from './Modal';
|
||||
|
||||
export const Modal = Object.assign(ModalRoot, {
|
||||
Header: ModalHeader,
|
||||
Body: ModalBody,
|
||||
Footer: ModalFooter,
|
||||
});
|
||||
|
||||
export const ModalRegion = Object.assign(ModalRegionPrimitive, {
|
||||
alert,
|
||||
confirm,
|
||||
});
|
||||
|
||||
export { useModal } from './useModal';
|
||||
export { ModalRenderer } from './ModalRenderer';
|
||||
export { AlertModal } from './AlertModal';
|
||||
export { ConfirmModal } from './ConfirmModal';
|
||||
43
web-app/app/shared/components/modal/store.ts
Normal file
43
web-app/app/shared/components/modal/store.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
interface ModalData {
|
||||
id: string;
|
||||
scopeId: string;
|
||||
Component: ComponentType<any>;
|
||||
props: Record<string, unknown>;
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
let modals: ModalData[] = [];
|
||||
let listeners: (() => void)[] = [];
|
||||
|
||||
const emitChange = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const SERVER_SNAPSHOT: ModalData[] = [];
|
||||
|
||||
export const modalStore = {
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners = [...listeners, listener];
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => modals,
|
||||
getServerSnapshot: () => SERVER_SNAPSHOT,
|
||||
addModal: (modal: ModalData) => {
|
||||
modals = [...modals, modal];
|
||||
emitChange();
|
||||
},
|
||||
removeModal: (id: string) => {
|
||||
modals = modals.filter((m) => m.id !== id);
|
||||
emitChange();
|
||||
},
|
||||
removeByScope: (scopeId: string) => {
|
||||
modals = modals.filter((m) => m.scopeId !== scopeId);
|
||||
emitChange();
|
||||
},
|
||||
};
|
||||
|
||||
export type { ModalData };
|
||||
50
web-app/app/shared/components/modal/useModal.ts
Normal file
50
web-app/app/shared/components/modal/useModal.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback, useEffect, useId } from 'react';
|
||||
|
||||
import { modalStore } from './store';
|
||||
|
||||
interface ModalControlProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const useModal = () => {
|
||||
const scopeId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
modalStore
|
||||
.getSnapshot()
|
||||
.filter((m) => m.scopeId === scopeId)
|
||||
.forEach((m) => m.resolve());
|
||||
modalStore.removeByScope(scopeId);
|
||||
};
|
||||
}, [scopeId]);
|
||||
|
||||
const show = useCallback(
|
||||
<P extends ModalControlProps>(
|
||||
Component: ComponentType<P>,
|
||||
props?: Omit<P, keyof ModalControlProps>
|
||||
): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
modalStore.addModal({
|
||||
id: crypto.randomUUID(),
|
||||
scopeId,
|
||||
Component,
|
||||
props: props ?? {},
|
||||
resolve,
|
||||
});
|
||||
}),
|
||||
[scopeId]
|
||||
);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
modalStore
|
||||
.getSnapshot()
|
||||
.filter((m) => m.scopeId === scopeId)
|
||||
.forEach((m) => m.resolve());
|
||||
modalStore.removeByScope(scopeId);
|
||||
}, [scopeId]);
|
||||
|
||||
return { show, hide };
|
||||
};
|
||||
@@ -49,7 +49,7 @@ export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChan
|
||||
const noNext = start + pageCount >= totalPages;
|
||||
|
||||
return (
|
||||
<nav className="text-kc-gray-99 text-sm" aria-label="pagination">
|
||||
<nav className="text-dabeeo-gray-99 text-sm" aria-label="pagination">
|
||||
<ul className="flex items-center justify-center list-none">
|
||||
<li className="flex">
|
||||
<AriaButton
|
||||
|
||||
@@ -6,7 +6,7 @@ const variantClassMap: Record<SectionVariant, string> = {
|
||||
base: 'flex-center w-full bg-white shadow-card p-6',
|
||||
card: 'flex-center bg-white shadow-card p-6',
|
||||
list: 'flex flex-col h-full min-h-0 gap-4 bg-white shadow-card p-6',
|
||||
searchFilterGray: 'flex items-center bg-gray-100 py-3 px-7 text-kc-black-2a text-sm font-medium gap-3',
|
||||
searchFilterGray: 'flex items-center bg-gray-100 py-3 px-7 text-dabeeo-black-2a text-sm font-medium gap-3',
|
||||
};
|
||||
|
||||
export type SectionProps = {
|
||||
|
||||
105
web-app/app/shared/components/select/Select.tsx
Normal file
105
web-app/app/shared/components/select/Select.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type Ref } from 'react';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Select as AriaSelect,
|
||||
type SelectProps as AriaSelectProps,
|
||||
FieldError,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
SelectValue,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
const selectStyle = tv({
|
||||
base: 'flex items-center text-start gap-4 w-full bg-white text-sm text-dabeeo-black-34 border border-dabeeo-gray-be cursor-default px-3 h-9 min-w-[120px] outline-0',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
|
||||
false:
|
||||
'hover:text-dabeeo-black-34 hover:border-dabeeo-black-34 data-pressed:text-dabeeo-black-34 data-pressed:border-dabeeo-black-34',
|
||||
},
|
||||
isReadOnly: {
|
||||
true: 'text-dabeeo-black-34 bg-dabeeo-yellow-secondary cursor-auto',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface SelectProps<T extends SelectOption>
|
||||
extends Omit<AriaSelectProps<T>, 'selectionMode' | 'onChange' | 'children'> {
|
||||
items?: Iterable<T>;
|
||||
onChange?: (value: T['value']) => void;
|
||||
maxHeight?: number;
|
||||
placement?: 'bottom' | 'top';
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const Select = <T extends SelectOption>(props: SelectProps<T>) => {
|
||||
const { className, items, onChange, maxHeight, placement = 'bottom', ref, isReadOnly, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<AriaSelect
|
||||
selectionMode="single"
|
||||
aria-label="선택"
|
||||
className={clsx('group/select', className)}
|
||||
{...restProps}
|
||||
onChange={(key) => {
|
||||
onChange?.(key as T['value']);
|
||||
}}
|
||||
>
|
||||
<AriaButton
|
||||
ref={ref}
|
||||
className={(renderProps) =>
|
||||
selectStyle({ ...renderProps, isDisabled: props.isDisabled || isReadOnly, isReadOnly })}
|
||||
{...{ isDisabled: props.isDisabled || isReadOnly }}
|
||||
>
|
||||
<SelectValue className="flex-1 text-sm data-placeholder:text-dabeeo-gray-99">
|
||||
{({ selectedText, defaultChildren }) => selectedText || defaultChildren}
|
||||
</SelectValue>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="5"
|
||||
viewBox="0 0 10 5"
|
||||
fill="none"
|
||||
className={clsx('group-data-open/select:rotate-180 transition', isReadOnly && 'hidden')}
|
||||
>
|
||||
<path d="M0 0H10L5 5L0 0Z" fill="#BEBEBE" />
|
||||
</svg>
|
||||
</AriaButton>
|
||||
<FieldError className="mt-0.5 text-dabeeo-red text-xs" />
|
||||
<Popover className="w-(--trigger-width)" offset={0.5} placement={placement}>
|
||||
<ListBox
|
||||
items={items}
|
||||
style={{ maxHeight: maxHeight ? `${maxHeight}px` : 'inherit' }}
|
||||
className={clsx(
|
||||
'w-full outline-0 box-border border-l border-r border-dabeeo-gray-be max-h-[inherit] bg-white overflow-auto',
|
||||
placement === 'top' ? 'border-t' : 'border-b'
|
||||
)}
|
||||
>
|
||||
{(item) => (
|
||||
<ListBoxItem
|
||||
id={item.value}
|
||||
className="outline-0 px-3 py-2 text-sm font-semibold text-dabeeo-black-34 data-selected:text-dabeeo-navy-main data-selected:bg-dabeeo-yellow-secondary data-selected:font-bold data-selected:hover:bg-dabeeo-gray-eb focus:bg-dabeeo-gray-eb hover:bg-dabeeo-gray-eb data-disabled:text-dabeeo-gray-be"
|
||||
isDisabled={!!item.isDisabled}
|
||||
>
|
||||
{item.label}
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
};
|
||||
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import type { DropTarget } from 'react-aria-components';
|
||||
import {
|
||||
Button,
|
||||
Collection,
|
||||
DropIndicator,
|
||||
Tree as ReactAriaTree,
|
||||
TreeItem,
|
||||
TreeItemContent,
|
||||
type TreeItemContentProps,
|
||||
type TreeItemContentRenderProps,
|
||||
type TreeItemProps,
|
||||
useDragAndDrop,
|
||||
} from 'react-aria-components';
|
||||
import type { Key, Selection } from 'react-stately';
|
||||
import { useTreeData } from 'react-stately';
|
||||
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TreeContextType = {
|
||||
onExpand?: (key: Key, item: TreeItemType) => void;
|
||||
onCollapse?: (key: Key, item: TreeItemType) => void;
|
||||
treeData?: ReturnType<typeof useTreeData<TreeItemType>>;
|
||||
treeOpenIcon?: React.ReactNode;
|
||||
treeCloseIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TreeContext = createContext<TreeContextType>({});
|
||||
|
||||
interface MyTreeItemProps extends Partial<TreeItemProps> {
|
||||
title: string;
|
||||
isLastChild?: boolean;
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
export type TreeItemType = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
type: 'directory' | 'file';
|
||||
value?: any;
|
||||
children?: TreeItemType[];
|
||||
};
|
||||
|
||||
export type TreeRef = {
|
||||
updateItem: (item: TreeItemType) => void;
|
||||
addItem: (item: TreeItemType, parentKey?: Key | null) => void;
|
||||
removeItem: (itemId: number) => void;
|
||||
expandAllItems: () => void;
|
||||
collapseAllItems: () => void;
|
||||
applyReorder: (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => void;
|
||||
clearItemsByParentKey: (parentKey: Key) => void;
|
||||
clearAllTrees: () => void;
|
||||
};
|
||||
|
||||
export type TreeProps = {
|
||||
items: TreeItemType[];
|
||||
selectionMode?: 'single' | 'multiple' | 'none';
|
||||
selectedKeys?: 'all' | Set<number | string>;
|
||||
selectedParentKey?: Key | null;
|
||||
onSelectionChange?: (keys: Selection) => void;
|
||||
enableDragAndDrop?: boolean;
|
||||
onReorder?: (
|
||||
sourceKey: number,
|
||||
targetKey: number,
|
||||
dropPosition: 'before' | 'after',
|
||||
ParentKey: number | null
|
||||
) => void;
|
||||
preventAutoReorder?: boolean;
|
||||
onExpand?: (key: Key, item: TreeItemType) => void;
|
||||
onCollapse?: (key: Key, item: TreeItemType) => void;
|
||||
onSelect?: (key: Key | null, item: TreeItemType | null) => void;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
treeOpenIcon?: React.ReactNode;
|
||||
treeCloseIcon?: React.ReactNode;
|
||||
persistSelectionOnCollapse?: boolean;
|
||||
};
|
||||
|
||||
const IconTreeBranch = () => (
|
||||
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0.5" y1="0" x2="0.5" y2="48" stroke="#343434" strokeDasharray="2 2" />
|
||||
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconTreeBranchLast = () => (
|
||||
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0.5" y1="0" x2="0.5" y2="24" stroke="#343434" strokeDasharray="2 2" />
|
||||
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MyTreeItemContentInner = ({
|
||||
level,
|
||||
hasChildItems,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
lastChild,
|
||||
children,
|
||||
isRoot,
|
||||
onExpandChange,
|
||||
}: {
|
||||
level: number;
|
||||
hasChildItems: boolean;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
lastChild?: boolean;
|
||||
children?: React.ReactNode;
|
||||
isRoot?: boolean;
|
||||
actualChildCount?: number;
|
||||
onExpandChange?: (isExpanded: boolean) => void;
|
||||
}) => {
|
||||
const { treeOpenIcon, treeCloseIcon } = useContext(TreeContext);
|
||||
if (hasChildItems || isRoot) {
|
||||
return (
|
||||
<Button
|
||||
className={clsx(
|
||||
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm',
|
||||
isSelected && 'bg-dabeeo-yellow'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16}px` }}
|
||||
slot="chevron"
|
||||
onClick={() => {
|
||||
onExpandChange?.(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{treeOpenIcon && treeCloseIcon ? (
|
||||
<div className="mr-3">{isExpanded ? treeOpenIcon : treeCloseIcon}</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-dabeeo-gray-eb border border-dabeeo-black-34 hover:bg-dabeeo-gray-da mr-4 shrink-0">
|
||||
{isExpanded ? '-' : '+'}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold">{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm pl-9',
|
||||
isSelected && 'bg-dabeeo-yellow'
|
||||
)}
|
||||
>
|
||||
<div className="mr-3 flex-shrink-0">{lastChild ? <IconTreeBranchLast /> : <IconTreeBranch />}</div>
|
||||
<span className="font-normal">{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MyTreeItemContent = (
|
||||
props: Omit<TreeItemContentProps, 'children'> & {
|
||||
children?: React.ReactNode;
|
||||
lastChild?: boolean;
|
||||
isRoot?: boolean;
|
||||
onExpandChange?: (isExpanded: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
<TreeItemContent>
|
||||
{(renderProps: TreeItemContentRenderProps) => {
|
||||
const { hasChildItems, isExpanded, isSelected, level } = renderProps;
|
||||
return (
|
||||
<>
|
||||
<MyTreeItemContentInner
|
||||
level={level}
|
||||
hasChildItems={hasChildItems}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
lastChild={props.lastChild}
|
||||
isRoot={props.isRoot}
|
||||
onExpandChange={props.onExpandChange}
|
||||
>
|
||||
{props.children}
|
||||
</MyTreeItemContentInner>
|
||||
<Button slot="drag" className="sr-only">
|
||||
드래그
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TreeItemContent>
|
||||
);
|
||||
};
|
||||
|
||||
const TreeDropIndicator = ({ target }: { target: DropTarget }) => {
|
||||
return (
|
||||
<DropIndicator
|
||||
target={target}
|
||||
className={({ isDropTarget }) =>
|
||||
clsx('w-full h-0 outline-2 outline-primary/50', isDropTarget ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MyTreeItem = (props: MyTreeItemProps) => {
|
||||
const { onExpand, onCollapse, treeData } = useContext(TreeContext);
|
||||
|
||||
const handleExpandChange = (isExpanded: boolean) => {
|
||||
if (!props.id || !treeData) return;
|
||||
|
||||
const item = treeData.getItem(props.id);
|
||||
if (!item) return;
|
||||
|
||||
if (isExpanded) {
|
||||
onExpand?.(props.id, item.value);
|
||||
} else {
|
||||
onCollapse?.(props.id, item.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
textValue={props.title}
|
||||
{...props}
|
||||
hasChildItems={props.isRoot ? true : undefined}
|
||||
data-item-id={props.id}
|
||||
className="
|
||||
data-[dragging]:opacity-60
|
||||
data-[drop-target]:outline
|
||||
data-[drop-target]:outline-2
|
||||
data-[drop-target]:outline-[var(--highlight-background)]
|
||||
data-[drop-target]:bg-[var(--highlight-overlay)]
|
||||
"
|
||||
>
|
||||
<MyTreeItemContent lastChild={props.isLastChild} isRoot={props.isRoot} onExpandChange={handleExpandChange}>
|
||||
{props.title}
|
||||
</MyTreeItemContent>
|
||||
{props.children}
|
||||
</TreeItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tree = forwardRef<TreeRef, TreeProps>(
|
||||
(
|
||||
{
|
||||
'aria-label': ariaLabel = 'Tree',
|
||||
items,
|
||||
selectionMode = 'single',
|
||||
selectedKeys,
|
||||
selectedParentKey,
|
||||
enableDragAndDrop = true,
|
||||
className,
|
||||
onSelectionChange,
|
||||
onReorder,
|
||||
preventAutoReorder = false,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onSelect,
|
||||
treeOpenIcon,
|
||||
treeCloseIcon,
|
||||
persistSelectionOnCollapse = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(new Set());
|
||||
const [treeKey, setTreeKey] = useState(0);
|
||||
|
||||
const handleExpandedChange = (keys: Set<Key>) => {
|
||||
setExpandedKeys(keys);
|
||||
};
|
||||
|
||||
const tree = useTreeData({
|
||||
initialItems: items,
|
||||
getKey: (item) => item.id,
|
||||
getChildren: (item) => item.children || [],
|
||||
});
|
||||
|
||||
const updateItem = (item: TreeItemType) => {
|
||||
tree.update(item.id, item);
|
||||
};
|
||||
|
||||
const addItem = (item: TreeItemType, parentKey?: Key | null) => {
|
||||
tree.getItem(-1) && tree.remove(-1);
|
||||
// 중복 체크: 이미 존재하는 아이템은 추가하지 않음
|
||||
const targetParentKey = parentKey ?? selectedParentKey ?? null;
|
||||
const existingItem = tree.getItem(item.id);
|
||||
|
||||
if (
|
||||
(existingItem && existingItem.parentKey === targetParentKey)
|
||||
|| (targetParentKey && targetParentKey === item.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
tree.append(targetParentKey, item);
|
||||
};
|
||||
|
||||
const removeItem = (itemId: number) => {
|
||||
tree.remove(itemId);
|
||||
const childrens = tree.getItem(selectedParentKey as Key)?.children;
|
||||
itemId !== -1 && childrens?.length === 1 && updateMyParentItem();
|
||||
};
|
||||
|
||||
const expandAllItems = () => {
|
||||
setExpandedKeys(new Set([...expandedKeys, ...tree.items.map((item) => item.key)]));
|
||||
};
|
||||
|
||||
const collapseAllItems = () => {
|
||||
setExpandedKeys(new Set());
|
||||
};
|
||||
|
||||
const clearAllTrees = () => {
|
||||
const keysToRemove = tree.items.map((item) => item.key);
|
||||
keysToRemove.forEach((key) => {
|
||||
tree.remove(key);
|
||||
});
|
||||
};
|
||||
|
||||
const clearItemsByParentKey = (parentKey: Key) => {
|
||||
const removeRecursively = (key: Key) => {
|
||||
const item = tree.getItem(key);
|
||||
if (item && item.children) {
|
||||
item.children.forEach((child) => {
|
||||
removeRecursively(child.key);
|
||||
});
|
||||
}
|
||||
tree.remove(key);
|
||||
};
|
||||
|
||||
const itemsToRemove: Key[] = [];
|
||||
tree.items.forEach((item) => {
|
||||
if (item.parentKey === parentKey) {
|
||||
itemsToRemove.push(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
itemsToRemove.forEach((key) => {
|
||||
removeRecursively(key);
|
||||
});
|
||||
};
|
||||
|
||||
const applyReorder = (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => {
|
||||
if (dropPosition === 'before') {
|
||||
tree.moveBefore(targetKey, new Set([sourceKey]));
|
||||
} else {
|
||||
tree.moveAfter(targetKey, new Set([sourceKey]));
|
||||
}
|
||||
};
|
||||
|
||||
const updateMyParentItem = () => {
|
||||
if (selectedParentKey) {
|
||||
const parentItem = tree.getItem(selectedParentKey);
|
||||
if (parentItem) {
|
||||
tree.update(selectedParentKey, {
|
||||
...parentItem.value,
|
||||
children: [],
|
||||
...(parentItem.value.value && { value: { ...parentItem.value.value, children: [] } }),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateItem,
|
||||
addItem,
|
||||
removeItem,
|
||||
expandAllItems,
|
||||
collapseAllItems,
|
||||
applyReorder,
|
||||
clearItemsByParentKey,
|
||||
clearAllTrees,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (persistSelectionOnCollapse) return;
|
||||
if (selectedKeys) {
|
||||
setExpandedKeys(new Set([...expandedKeys, ...selectedKeys]));
|
||||
}
|
||||
}, [selectedKeys, persistSelectionOnCollapse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items) {
|
||||
setTreeKey((prev) => prev + 1);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
onSelectionChange?.(keys);
|
||||
|
||||
if (onSelect && selectionMode === 'single' && keys !== 'all') {
|
||||
const selectedKey = keys instanceof Set ? Array.from(keys)[0] : null;
|
||||
if (selectedKey) {
|
||||
const item = tree.getItem(selectedKey);
|
||||
if (item) {
|
||||
onSelect(selectedKey, item ? item.value : null);
|
||||
}
|
||||
} else {
|
||||
onSelect(selectedKey, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({
|
||||
'text/plain': tree.getItem(key)?.value.title || '',
|
||||
})),
|
||||
renderDropIndicator(target) {
|
||||
return <TreeDropIndicator target={target} />;
|
||||
},
|
||||
onReorder(e) {
|
||||
const targetItem = tree.getItem(e.target.key);
|
||||
const sourceKey = Array.from(e.keys)[0] as number;
|
||||
const dropPosition = e.target.dropPosition === 'before' ? 'before' : 'after';
|
||||
const targetParentKey = Number(targetItem?.parentKey) || null;
|
||||
|
||||
if (preventAutoReorder) {
|
||||
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
|
||||
} else {
|
||||
if (dropPosition === 'before') {
|
||||
tree.moveBefore(e.target.key, e.keys);
|
||||
} else {
|
||||
tree.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const calculateIsLastChild = (item: ReturnType<typeof tree.getItem>) => {
|
||||
if (!item || !item.parentKey) return false;
|
||||
|
||||
const parent = tree.getItem(item.parentKey);
|
||||
if (!parent) return false;
|
||||
|
||||
const siblings = parent.children || [];
|
||||
const index = siblings.findIndex((sibling) => sibling.key === item.key);
|
||||
return index === siblings.length - 1;
|
||||
};
|
||||
|
||||
// MEMO: tree.items가 변경될 때 전체 트리를 다시 렌더링하기 위해 사용하는 key
|
||||
// 현재는 하나의 item이 변경되어도 전체 트리가 다시 렌더링됨. 변경된 item과 같은 부모 아래의 아이템들만 선택적으로 렌더링되도록 개선 필요함
|
||||
const treeRenderKey = useMemo(() => {
|
||||
const generateTreeHash = (items: typeof tree.items): string => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const childrenHash = item.children ? generateTreeHash(item.children) : '';
|
||||
return `${item.key}-${item.children?.length || 0}-${childrenHash}`;
|
||||
})
|
||||
.join('|');
|
||||
};
|
||||
return generateTreeHash(tree.items);
|
||||
}, [tree.items]);
|
||||
|
||||
return (
|
||||
<TreeContext.Provider value={{ onExpand, onCollapse, treeData: tree, treeOpenIcon, treeCloseIcon }}>
|
||||
<ReactAriaTree
|
||||
key={`${treeKey}-${treeRenderKey}`}
|
||||
aria-label={ariaLabel}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
items={tree.items}
|
||||
dragAndDropHooks={enableDragAndDrop ? dragAndDropHooks : undefined}
|
||||
className={clsx(className, 'w-full')}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
>
|
||||
{function renderItem(item) {
|
||||
const isLastChild = calculateIsLastChild(item);
|
||||
return (
|
||||
<MyTreeItem
|
||||
title={item.value.title || ''}
|
||||
isLastChild={isLastChild}
|
||||
isRoot={item.value.type === 'directory'}
|
||||
>
|
||||
<Collection items={item.children || []}>{renderItem}</Collection>
|
||||
</MyTreeItem>
|
||||
);
|
||||
}}
|
||||
</ReactAriaTree>
|
||||
</TreeContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tree.displayName = 'Tree';
|
||||
Reference in New Issue
Block a user