diff --git a/web-app/app/shared/components/button/Button.tsx b/web-app/app/shared/components/button/Button.tsx index d9eda45..f25ff51 100644 --- a/web-app/app/shared/components/button/Button.tsx +++ b/web-app/app/shared/components/button/Button.tsx @@ -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', }, ], }); diff --git a/web-app/app/shared/components/calendar/Calendar.tsx b/web-app/app/shared/components/calendar/Calendar.tsx index d63e673..a15b1e6 100644 --- a/web-app/app/shared/components/calendar/Calendar.tsx +++ b/web-app/app/shared/components/calendar/Calendar.tsx @@ -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() { navButton(renderProps)}> - + navButton(renderProps)}> @@ -173,7 +173,7 @@ export function CalendarGridHeader() { return ( {(day) => ( - + {day} )} diff --git a/web-app/app/shared/components/calendar/RangeCalendar.tsx b/web-app/app/shared/components/calendar/RangeCalendar.tsx index 987cab1..fb52045 100644 --- a/web-app/app/shared/components/calendar/RangeCalendar.tsx +++ b/web-app/app/shared/components/calendar/RangeCalendar.tsx @@ -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]', diff --git a/web-app/app/shared/components/datePicker/DatePicker.tsx b/web-app/app/shared/components/datePicker/DatePicker.tsx index f3b1987..7003aaf 100644 --- a/web-app/app/shared/components/datePicker/DatePicker.tsx +++ b/web-app/app/shared/components/datePicker/DatePicker.tsx @@ -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 > - + diff --git a/web-app/app/shared/components/datePicker/DateRangePicker.tsx b/web-app/app/shared/components/datePicker/DateRangePicker.tsx index c88f176..41b99e1 100644 --- a/web-app/app/shared/components/datePicker/DateRangePicker.tsx +++ b/web-app/app/shared/components/datePicker/DateRangePicker.tsx @@ -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({ > - + diff --git a/web-app/app/shared/components/descriptions/Descriptions.tsx b/web-app/app/shared/components/descriptions/Descriptions.tsx new file mode 100644 index 0000000..13f682e --- /dev/null +++ b/web-app/app/shared/components/descriptions/Descriptions.tsx @@ -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 ( +
+ {items.map((item, index) => { + const { key, ...itemProps } = item; + return ( + + ); + })} +
+ ); +}; diff --git a/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx new file mode 100644 index 0000000..eb53f00 --- /dev/null +++ b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx @@ -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 ( +
+
+ {title} +
+
+ {content} +
+
+ ); +}; diff --git a/web-app/app/shared/components/inputGroup/InputGroup.tsx b/web-app/app/shared/components/inputGroup/InputGroup.tsx index 04f7ac5..b087edc 100644 --- a/web-app/app/shared/components/inputGroup/InputGroup.tsx +++ b/web-app/app/shared/components/inputGroup/InputGroup.tsx @@ -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', }, }, }, diff --git a/web-app/app/shared/components/modal/AlertModal.tsx b/web-app/app/shared/components/modal/AlertModal.tsx new file mode 100644 index 0000000..5b30229 --- /dev/null +++ b/web-app/app/shared/components/modal/AlertModal.tsx @@ -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 ( + + {({ close }) => } + + ); +}; + +function AlertModalContent({ + close, + title, + content, + confirmText = '확인', + onConfirm, +}: { + close: () => void; + title?: string; + content?: ReactNode; + confirmText?: string; + onConfirm?: () => void; +}) { + const handleConfirm = () => { + onConfirm?.(); + close(); + }; + + return ( + <> + {title && {title}} + +

{content}

+
+ + + + + ); +} diff --git a/web-app/app/shared/components/modal/ConfirmModal.tsx b/web-app/app/shared/components/modal/ConfirmModal.tsx new file mode 100644 index 0000000..f1dcd04 --- /dev/null +++ b/web-app/app/shared/components/modal/ConfirmModal.tsx @@ -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; + onCancel?: () => void; +} + +export const ConfirmModal = (props: ConfirmModalProps) => { + const { isOpen, onOpenChange, title, ...contentProps } = props; + + return ( + + {({ close }) => } + + ); +}; + +function ConfirmModalContent({ + close, + title, + content, + confirmText = '확인', + cancelText = '취소', + onConfirm, + onCancel, +}: { + close: () => void; + title?: string; + content?: ReactNode; + confirmText?: string; + cancelText?: string; + onConfirm?: () => Promise | void; + onCancel?: () => void; +}) { + const [isPending, startTransition] = useTransition(); + + const handleConfirm = () => { + if (onConfirm) { + startTransition(async () => { + await onConfirm(); + close(); + }); + } else { + close(); + } + }; + + const handleCancel = () => { + onCancel?.(); + close(); + }; + + return ( + <> + {title && {title}} + +

{content}

+
+ + + + + + ); +} diff --git a/web-app/app/shared/components/modal/Modal.tsx b/web-app/app/shared/components/modal/Modal.tsx new file mode 100644 index 0000000..c44bc8c --- /dev/null +++ b/web-app/app/shared/components/modal/Modal.tsx @@ -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 { + className?: string; +} +const ModalRoot = (props: ModalRootProps) => { + const { isOpen, onOpenChange, isKeyboardDismissDisabled, isDismissable, className, ...dialogProps } = props; + return ( + + + + {props.children} + + + + ); +}; + +interface ModalHeaderProps { + hasCloseButton?: boolean; + children: React.ReactNode; +} +const ModalHeader = (props: ModalHeaderProps) => { + const ctx = use(OverlayTriggerStateContext); + + const { hasCloseButton = true } = props; + return ( +
+ + {props.children} + + {hasCloseButton && ( + + + + + + )} +
+ ); +}; + +interface ModalBodyProps extends React.PropsWithChildren { + className?: string; +} +const ModalBody = (props: ModalBodyProps) => { + return
{props.children}
; +}; + +interface ModalFooterProps extends React.PropsWithChildren {} +const ModalFooter = (props: ModalFooterProps) => { + return
{props.children}
; +}; + +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) => ( + { + if (!isOpen) { + _modals = _modals.filter((f) => f !== modal); + emitChange(); + } + }} + > + {({ close }) => ( + <> + {modal.title && {modal.title}} + +

{modal.content}

+
+ + + {modal.typeof === 'confirm' && ( + + )} + + + )} +
+ ))} + + ); +}; +export { alert, confirm, ModalBody, ModalFooter, ModalHeader, ModalRegion, ModalRoot }; +export type { ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalRootProps }; + diff --git a/web-app/app/shared/components/modal/ModalRenderer.tsx b/web-app/app/shared/components/modal/ModalRenderer.tsx new file mode 100644 index 0000000..ec4f41b --- /dev/null +++ b/web-app/app/shared/components/modal/ModalRenderer.tsx @@ -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 ( + { + if (!open) { + resolve(); + modalStore.removeModal(id); + } + }} + /> + ); + })} + + ); +}; diff --git a/web-app/app/shared/components/modal/index.ts b/web-app/app/shared/components/modal/index.ts new file mode 100644 index 0000000..d5d6383 --- /dev/null +++ b/web-app/app/shared/components/modal/index.ts @@ -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'; diff --git a/web-app/app/shared/components/modal/store.ts b/web-app/app/shared/components/modal/store.ts new file mode 100644 index 0000000..ac2d9dc --- /dev/null +++ b/web-app/app/shared/components/modal/store.ts @@ -0,0 +1,43 @@ +import type { ComponentType } from 'react'; + +interface ModalData { + id: string; + scopeId: string; + Component: ComponentType; + props: Record; + 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 }; diff --git a/web-app/app/shared/components/modal/useModal.ts b/web-app/app/shared/components/modal/useModal.ts new file mode 100644 index 0000000..d637246 --- /dev/null +++ b/web-app/app/shared/components/modal/useModal.ts @@ -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( +

( + Component: ComponentType

, + props?: Omit + ): Promise => + 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 }; +}; diff --git a/web-app/app/shared/components/pagination/Pagination.tsx b/web-app/app/shared/components/pagination/Pagination.tsx index 294555f..1037bd7 100644 --- a/web-app/app/shared/components/pagination/Pagination.tsx +++ b/web-app/app/shared/components/pagination/Pagination.tsx @@ -49,7 +49,7 @@ export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChan const noNext = start + pageCount >= totalPages; return ( -