@uniai-fe/uds-templates 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +88 -1
  2. package/dist/styles.css +2051 -2266
  3. package/package.json +5 -3
  4. package/src/auth/common/complete/Template.tsx +47 -0
  5. package/src/auth/common/complete/img/circle-check-complete.svg +4 -0
  6. package/src/auth/common/complete/index.scss +38 -0
  7. package/src/auth/common/complete/types.ts +15 -0
  8. package/src/auth/common/container/header/StageHeader.tsx +61 -0
  9. package/src/auth/common/container/header/index.tsx +5 -0
  10. package/src/auth/common/container/header/stage-header.scss +50 -0
  11. package/src/{components/auth → auth/common}/container/index.tsx +2 -0
  12. package/src/auth/common/find/hooks/useFindAccountForm.ts +79 -0
  13. package/src/auth/common/find/markup/CodeStep.tsx +166 -0
  14. package/src/auth/common/find/markup/Header.tsx +46 -0
  15. package/src/auth/common/find/markup/InfoStep.tsx +109 -0
  16. package/src/auth/common/find/styles/email.scss +55 -0
  17. package/src/auth/common/find/styles/find-account.scss +4 -0
  18. package/src/auth/common/find/styles/layout.scss +19 -0
  19. package/src/auth/common/find/styles/password.scss +39 -0
  20. package/src/auth/common/find/styles/result.scss +78 -0
  21. package/src/auth/common/find/types/forms.ts +30 -0
  22. package/src/auth/common/find/types/index.ts +121 -0
  23. package/src/auth/common/find/utils/composeFieldProps.ts +45 -0
  24. package/src/auth/common/password/constants.ts +19 -0
  25. package/src/auth/common/password/hooks/useCheckPassword.ts +133 -0
  26. package/src/auth/common/password/img/check-password.svg +3 -0
  27. package/src/auth/common/password/markup/PasswordSetField.tsx +250 -0
  28. package/src/auth/common/password/styles/password-set-field.scss +49 -0
  29. package/src/auth/common/password/types.ts +142 -0
  30. package/src/auth/common/password/utils/composePasswordFieldProps.ts +44 -0
  31. package/src/auth/find-account.ts +28 -0
  32. package/src/auth/find-id/hooks/index.ts +1 -0
  33. package/src/auth/find-id/index.scss +1 -0
  34. package/src/auth/find-id/index.ts +23 -0
  35. package/src/auth/find-id/markup/StepComplete.tsx +58 -0
  36. package/src/auth/find-id/markup/StepIdentify.tsx +46 -0
  37. package/src/auth/find-id/markup/StepVerifyCode.tsx +48 -0
  38. package/src/auth/find-id/types/index.ts +66 -0
  39. package/src/auth/find-password/index.scss +1 -0
  40. package/src/auth/find-password/index.ts +30 -0
  41. package/src/auth/find-password/markup/StepComplete.tsx +30 -0
  42. package/src/auth/find-password/markup/StepIdentify.tsx +45 -0
  43. package/src/auth/find-password/markup/StepResetPassword.tsx +150 -0
  44. package/src/auth/find-password/markup/StepVerifyCode.tsx +48 -0
  45. package/src/auth/index.tsx +41 -0
  46. package/src/{components/auth → auth}/login/index.tsx +1 -7
  47. package/src/{components/auth → auth}/login/markup/Container.tsx +1 -1
  48. package/src/{components/auth → auth}/login/markup/FormField.tsx +2 -2
  49. package/src/{components/auth → auth}/login/types/props.ts +13 -13
  50. package/src/auth/login/types.ts +2 -0
  51. package/src/auth/signup/hooks/index.ts +3 -0
  52. package/src/auth/signup/hooks/useSignupAccountForm.ts +101 -0
  53. package/src/auth/signup/hooks/useSignupUserInfoForm.ts +88 -0
  54. package/src/auth/signup/hooks/useSignupVerificationForm.ts +77 -0
  55. package/src/auth/signup/img/check-agree.svg +3 -0
  56. package/src/auth/signup/img/chevron-open-detail.svg +3 -0
  57. package/src/auth/signup/index.ts +27 -0
  58. package/src/auth/signup/markup/AccountForm.tsx +113 -0
  59. package/src/auth/signup/markup/Complete.tsx +59 -0
  60. package/src/auth/signup/markup/Template.tsx +110 -0
  61. package/src/auth/signup/markup/UserInfoForm.tsx +107 -0
  62. package/src/auth/signup/markup/VerificationForm.tsx +285 -0
  63. package/src/auth/signup/markup/index.ts +5 -0
  64. package/src/auth/signup/styles/signup.scss +187 -0
  65. package/src/auth/signup/types/hooks.ts +86 -0
  66. package/src/auth/signup/types/index.ts +2 -0
  67. package/src/auth/signup/types/props.ts +145 -0
  68. package/src/auth/signup/utils/composeFieldProps.ts +50 -0
  69. package/src/auth/signup/utils/getSignupFieldDefaultValue.ts +40 -0
  70. package/src/index.scss +5 -3
  71. package/src/index.tsx +3 -2
  72. package/src/modal/core/components/Container.tsx +41 -0
  73. package/src/modal/core/components/FooterButtons.tsx +132 -0
  74. package/src/modal/core/components/Provider.tsx +28 -0
  75. package/src/modal/core/components/Root.tsx +93 -0
  76. package/src/modal/core/hooks/useModal.ts +136 -0
  77. package/src/modal/core/jotai/atoms.ts +10 -0
  78. package/src/modal/index.scss +4 -0
  79. package/src/modal/index.tsx +16 -0
  80. package/src/modal/styles/animations.scss +24 -0
  81. package/src/modal/styles/base.scss +45 -0
  82. package/src/modal/styles/container.scss +138 -0
  83. package/src/modal/styles/dimmer.scss +23 -0
  84. package/src/modal/templates/Alert.tsx +104 -0
  85. package/src/modal/templates/Dialog.tsx +112 -0
  86. package/src/modal/types/footer.ts +36 -0
  87. package/src/modal/types/index.ts +21 -0
  88. package/src/modal/types/options.ts +6 -0
  89. package/src/modal/types/state.ts +31 -0
  90. package/src/modal/types/templates.ts +32 -0
  91. package/src/page-frame/mobile/header/PageFrameMobileHeader.tsx +52 -0
  92. package/src/page-frame/mobile/header/index.ts +4 -0
  93. package/src/page-frame/mobile/header/page-frame-mobile-header.scss +48 -0
  94. package/src/page-frame/mobile/img/chevron-backward.svg +3 -0
  95. package/src/components/auth/index.tsx +0 -20
  96. package/src/components/auth/login/types.ts +0 -2
  97. /package/src/{components/auth → auth/common}/container/AuthContainer.tsx +0 -0
  98. /package/src/{components/auth → auth/common}/container/index.scss +0 -0
  99. /package/src/{components/auth → auth/common}/container/types.ts +0 -0
  100. /package/src/{components/auth → auth}/login/data/valid-options.ts +0 -0
  101. /package/src/{components/auth → auth}/login/hooks/index.ts +0 -0
  102. /package/src/{components/auth → auth}/login/hooks/useAuthLoginForm.ts +0 -0
  103. /package/src/{components/auth → auth}/login/index.scss +0 -0
  104. /package/src/{components/auth → auth}/login/markup/LinkButtons.tsx +0 -0
  105. /package/src/{components/auth → auth}/login/styles/login.scss +0 -0
  106. /package/src/{components/auth → auth}/login/types/form.ts +0 -0
  107. /package/src/{components/auth → auth}/login/types/hooks.ts +0 -0
  108. /package/src/{components/page-frame → page-frame}/container/PageFrameContainer.tsx +0 -0
  109. /package/src/{components/page-frame → page-frame}/container/index.scss +0 -0
  110. /package/src/{components/page-frame → page-frame}/container/index.tsx +0 -0
  111. /package/src/{components/page-frame → page-frame}/container/types.ts +0 -0
  112. /package/src/{components/page-frame → page-frame}/index.tsx +0 -0
  113. /package/src/{components/page-frame → page-frame}/mobile/PageFrameMobile.tsx +0 -0
  114. /package/src/{components/page-frame → page-frame}/mobile/index.scss +0 -0
  115. /package/src/{components/page-frame → page-frame}/mobile/index.tsx +0 -0
  116. /package/src/{components/page-frame → page-frame}/mobile/types.ts +0 -0
  117. /package/src/{components/page-frame → page-frame}/navigation/PageFrameNavigation.tsx +0 -0
  118. /package/src/{components/page-frame → page-frame}/navigation/index.scss +0 -0
  119. /package/src/{components/page-frame → page-frame}/navigation/index.tsx +0 -0
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+ import type { InputProps, InputState } from "@uniai-fe/uds-primitives";
3
+ import type { AuthSignupFieldProps } from "../types";
4
+
5
+ /**
6
+ * 필드 helper 상태; helper 텍스트 및 상태를 전달한다.
7
+ */
8
+ export interface AuthSignupHelperState {
9
+ text?: ReactNode;
10
+ state?: InputState;
11
+ }
12
+
13
+ /**
14
+ * 입력 필드 구성 요소; attr/props를 병합해 label/helper/state를 일관되게 만든다.
15
+ * @param {AuthSignupFieldProps<TProps>} config 필드 설정
16
+ * @param {AuthSignupHelperState} [helper] 훅에서 계산한 helper 정보
17
+ */
18
+ export const composeSignupFieldProps = <TProps extends InputProps>(
19
+ config: AuthSignupFieldProps<TProps>,
20
+ helper?: AuthSignupHelperState,
21
+ ): TProps => {
22
+ // attr + props를 합쳐 RHF register/label/helper className을 동기화한다.
23
+ const baseProps = {
24
+ ...(config.attr ?? {}),
25
+ ...(config.props ?? ({} as TProps)),
26
+ } as TProps;
27
+ const mergedLabelProps = config.labelClassName
28
+ ? {
29
+ ...baseProps.labelProps,
30
+ className: config.labelClassName,
31
+ }
32
+ : baseProps.labelProps;
33
+ const mergedHelperProps = config.helperClassName
34
+ ? {
35
+ ...baseProps.helperProps,
36
+ className: config.helperClassName,
37
+ }
38
+ : baseProps.helperProps;
39
+
40
+ return {
41
+ ...baseProps,
42
+ label: config.label ?? baseProps.label,
43
+ helper: helper?.text ?? config.helper ?? baseProps.helper,
44
+ state: helper?.state ?? baseProps.state,
45
+ block: config.block ?? baseProps.block ?? true,
46
+ className: config.className ?? baseProps.className,
47
+ labelProps: mergedLabelProps,
48
+ helperProps: mergedHelperProps,
49
+ };
50
+ };
@@ -0,0 +1,40 @@
1
+ import type { AuthSignupFieldProps } from "../types";
2
+
3
+ const stringifyValue = (value: unknown): string =>
4
+ value === undefined || value === null ? "" : String(value);
5
+
6
+ /**
7
+ * 필드 설정에서 defaultValue를 추출해 RHF 초기값으로 사용한다.
8
+ * props.defaultValue → attr.defaultValue → props.value 순으로 우선한다.
9
+ */
10
+ export const getSignupFieldDefaultValue = (
11
+ config: AuthSignupFieldProps,
12
+ ): string => {
13
+ if (config.props) {
14
+ if (
15
+ "defaultValue" in config.props &&
16
+ config.props.defaultValue !== undefined
17
+ ) {
18
+ return stringifyValue(config.props.defaultValue);
19
+ }
20
+ if ("value" in config.props && config.props.value !== undefined) {
21
+ return stringifyValue(config.props.value);
22
+ }
23
+ }
24
+
25
+ if (config.attr) {
26
+ if (
27
+ "defaultValue" in config.attr &&
28
+ config.attr.defaultValue !== undefined
29
+ ) {
30
+ return stringifyValue(config.attr.defaultValue);
31
+ }
32
+ if ("value" in config.attr && config.attr.value !== undefined) {
33
+ return stringifyValue(config.attr.value);
34
+ }
35
+ }
36
+
37
+ return "";
38
+ };
39
+
40
+ export default getSignupFieldDefaultValue;
package/src/index.scss CHANGED
@@ -1,6 +1,8 @@
1
+ /* templates styles는 foundation/primitives 순으로 import된 이후를 가정한다. */
1
2
  @use "@uniai-fe/uds-primitives/styles";
2
3
 
3
4
  /* 템플릿 레벨 스타일을 통합해 서비스 앱이 단일 엔트리만 import하도록 구성한다. */
4
- @use "./components/page-frame/container/index.scss" as *;
5
- @use "./components/page-frame/mobile/index.scss" as *;
6
- @use "./components/page-frame/navigation/index.scss" as *;
5
+ @use "./page-frame/container/index.scss" as *;
6
+ @use "./page-frame/mobile/index.scss" as *;
7
+ @use "./page-frame/navigation/index.scss" as *;
8
+ @use "./modal/index.scss" as *;
package/src/index.tsx CHANGED
@@ -1,2 +1,3 @@
1
- export * from "./components/page-frame";
2
- export * from "./components/auth";
1
+ export * from "./page-frame";
2
+ export * from "./auth";
3
+ export * from "./modal";
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import type {
4
+ ModalFooterButton,
5
+ ModalSections,
6
+ ModalStackKey,
7
+ } from "../../types";
8
+ import { ModalFooterButtons } from "./FooterButtons";
9
+
10
+ /**
11
+ * 모달 컨테이너; header/body/footer 슬롯만 담당한다.
12
+ * @component
13
+ * @param {ModalSections & {stackKey: ModalStackKey; footerButtons?: ModalFooterButton[]}} props
14
+ * @param {React.ReactNode} [props.header] 헤더 슬롯
15
+ * @param {React.ReactNode} props.body 본문 슬롯
16
+ * @param {React.ReactNode} [props.footer] footer 슬롯
17
+ * @param {ModalFooterButton[]} [props.footerButtons] 버튼 스펙 배열
18
+ */
19
+ export function ModalContainer({
20
+ stackKey,
21
+ header,
22
+ body,
23
+ footer,
24
+ footerButtons,
25
+ }: ModalSections & {
26
+ stackKey: ModalStackKey;
27
+ footerButtons?: ModalFooterButton[];
28
+ }) {
29
+ return (
30
+ <div className="uds-modal-container">
31
+ {header ? <div className="uds-modal-header">{header}</div> : null}
32
+ <div className="uds-modal-body">{body}</div>
33
+ {footer ? <div className="uds-modal-footer">{footer}</div> : null}
34
+ {!footer && footerButtons && footerButtons.length ? (
35
+ <div className="uds-modal-footer">
36
+ <ModalFooterButtons stackKey={stackKey} buttons={footerButtons} />
37
+ </div>
38
+ ) : null}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { Button } from "@uniai-fe/uds-primitives";
5
+
6
+ import { useModal } from "../hooks/useModal";
7
+
8
+ import type {
9
+ ModalFooterButton,
10
+ ModalStackKey,
11
+ ModalFooterButtonAppearance,
12
+ ModalFooterButtonLinkOptions,
13
+ } from "../../types";
14
+
15
+ const resolveAppearance = (
16
+ appearance?: ModalFooterButtonAppearance,
17
+ ): ModalFooterButtonAppearance => appearance ?? "default";
18
+
19
+ const createAnchorProps = (
20
+ linkOptions?: ModalFooterButtonLinkOptions,
21
+ ):
22
+ | {
23
+ as: "a";
24
+ href: string;
25
+ target?: string;
26
+ rel?: string;
27
+ }
28
+ | Record<string, never> => {
29
+ if (!linkOptions) return {};
30
+ return {
31
+ as: "a",
32
+ href: linkOptions.href,
33
+ target: linkOptions.target,
34
+ rel: linkOptions.rel,
35
+ };
36
+ };
37
+
38
+ /**
39
+ * 모달 footer 버튼 리스트 렌더러.
40
+ * @component
41
+ * @param {{ stackKey: ModalStackKey; buttons: ModalFooterButton[] }} props 버튼 정의 목록
42
+ */
43
+ export function ModalFooterButtons({
44
+ stackKey,
45
+ buttons,
46
+ }: {
47
+ stackKey: ModalStackKey;
48
+ buttons: ModalFooterButton[];
49
+ }) {
50
+ const { closeModal } = useModal();
51
+ // appearance 정보를 선계산해 footer group 스타일을 판별한다.
52
+ const resolvedButtons = buttons.map(button => ({
53
+ ...button,
54
+ appearance: resolveAppearance(button.defaultOptions.appearance),
55
+ }));
56
+ const groupAppearance = resolvedButtons.every(
57
+ button => button.appearance === "text",
58
+ )
59
+ ? "text"
60
+ : "default";
61
+
62
+ return (
63
+ <div
64
+ className="uds-modal-footer-buttons"
65
+ data-count={resolvedButtons.length}
66
+ data-appearance={groupAppearance}
67
+ >
68
+ {resolvedButtons.map((button, index) => {
69
+ const {
70
+ defaultOptions,
71
+ linkOptions,
72
+ closeOnClick = button.role === "close",
73
+ appearance,
74
+ } = button;
75
+ const anchorProps = createAnchorProps(linkOptions);
76
+ const closeAndCleanup = () => {
77
+ // footer 버튼마다 주입된 이벤트 → close 순서를 동일하게 유지한다.
78
+ defaultOptions.onClick?.();
79
+ if (closeOnClick) {
80
+ closeModal({ stackKey });
81
+ }
82
+ };
83
+ const resolvedBlock =
84
+ typeof defaultOptions.block === "boolean"
85
+ ? defaultOptions.block
86
+ : true;
87
+
88
+ const baseClassName = clsx(
89
+ "uds-modal-footer-button",
90
+ appearance === "text"
91
+ ? "uds-modal-footer-button-text"
92
+ : "uds-modal-footer-button-solid",
93
+ defaultOptions.className,
94
+ );
95
+ const commonProps = {
96
+ key: `uds-modal-footer-button-${button.stackKey}-${button.role}-${index}`,
97
+ className: baseClassName,
98
+ disabled: defaultOptions.disabled,
99
+ block: resolvedBlock,
100
+ onClick: closeAndCleanup,
101
+ ...anchorProps,
102
+ };
103
+
104
+ if (appearance === "text") {
105
+ const textPriority =
106
+ defaultOptions.priority && defaultOptions.priority !== "primary"
107
+ ? defaultOptions.priority
108
+ : "secondary";
109
+ return (
110
+ <Button.Text
111
+ {...commonProps}
112
+ size={defaultOptions.textSize ?? "large"}
113
+ priority={textPriority}
114
+ >
115
+ {defaultOptions.label}
116
+ </Button.Text>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <Button.Default
122
+ {...commonProps}
123
+ scale={defaultOptions.scale ?? "solid-large"}
124
+ priority={defaultOptions.priority ?? "primary"}
125
+ >
126
+ {defaultOptions.label}
127
+ </Button.Default>
128
+ );
129
+ })}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { Fragment } from "react";
4
+
5
+ import { useModal } from "../hooks/useModal";
6
+ import { ModalRoot } from "./Root";
7
+
8
+ /**
9
+ * 서비스 루트에서 1회 배치되는 모달 Provider.
10
+ * @component
11
+ */
12
+ export function ModalProvider() {
13
+ const { modalStacks } = useModal();
14
+
15
+ if (!modalStacks.length) return null;
16
+
17
+ return (
18
+ <Fragment>
19
+ {modalStacks.map((stack, index) => (
20
+ <ModalRoot
21
+ key={`uds-modal-${stack.stackKey}`}
22
+ {...stack}
23
+ index={index}
24
+ />
25
+ ))}
26
+ </Fragment>
27
+ );
28
+ }
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo } from "react";
4
+ import type { CSSProperties } from "react";
5
+ import clsx from "clsx";
6
+
7
+ import { useModal } from "../hooks/useModal";
8
+ import { ModalContainer } from "./Container";
9
+
10
+ import type { ModalProps, ModalShowState, ModalState } from "../../types";
11
+
12
+ type ModalRootProps = ModalState & {
13
+ index: number;
14
+ };
15
+
16
+ const resolveDataState = (show: ModalShowState): string => {
17
+ if (show === "init") return "init";
18
+ return show ? "open" : "closed";
19
+ };
20
+
21
+ /**
22
+ * 모달 Root; overlay 클릭 닫기와 상태 전환을 담당한다.
23
+ * @component
24
+ * @param {ModalRootProps} props 루트 속성
25
+ * @param {string} props.stackKey 모달 스택 식별자
26
+ * @param {ModalProps} props.modalProps 모달 렌더링에 필요한 상태
27
+ * @param {number} props.index Provider에서 전달한 스택 index
28
+ * @param {string} [props.className] 사용자 정의 className
29
+ */
30
+ export function ModalRoot({
31
+ stackKey,
32
+ modalProps,
33
+ index,
34
+ className,
35
+ }: ModalRootProps) {
36
+ const { updateModal, closeModal } = useModal();
37
+
38
+ useEffect(() => {
39
+ if (modalProps.show === "init") {
40
+ updateModal({ stackKey, modalProps: { show: true } });
41
+ }
42
+ }, [modalProps.show, stackKey, updateModal]);
43
+
44
+ const handleClose = useCallback(() => {
45
+ closeModal({ stackKey });
46
+ }, [closeModal, stackKey]);
47
+
48
+ const stopPropagation = useCallback(
49
+ (event: React.MouseEvent<HTMLDivElement>) => {
50
+ event.stopPropagation();
51
+ },
52
+ [],
53
+ );
54
+
55
+ const dataState = useMemo(
56
+ () => resolveDataState(modalProps.show),
57
+ [modalProps.show],
58
+ );
59
+
60
+ const layerStyle = useMemo(
61
+ () =>
62
+ ({
63
+ "--uds-modal-index": index,
64
+ }) as CSSProperties,
65
+ [index],
66
+ );
67
+
68
+ return (
69
+ <div
70
+ className={clsx("uds-modal-root", className)}
71
+ data-state={dataState}
72
+ style={layerStyle}
73
+ onClick={handleClose}
74
+ role="presentation"
75
+ >
76
+ <div className="uds-modal-dimmer" />
77
+ <div
78
+ className="uds-modal-surface"
79
+ role="dialog"
80
+ aria-modal="true"
81
+ onClick={stopPropagation}
82
+ >
83
+ <ModalContainer
84
+ stackKey={stackKey}
85
+ header={modalProps.header}
86
+ body={modalProps.body}
87
+ footer={modalProps.footer}
88
+ footerButtons={modalProps.footerButtons}
89
+ />
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { useAtom } from "jotai";
5
+
6
+ import { modalStackAtom } from "../jotai/atoms";
7
+
8
+ import type {
9
+ ModalCloseRequest,
10
+ ModalState,
11
+ ModalStatePatch,
12
+ ModalStackKey,
13
+ } from "../../types";
14
+
15
+ const DEFAULT_CLOSE_DELAY = 400;
16
+
17
+ type CloseFlagState = {
18
+ stackKey: ModalStackKey;
19
+ showDelay: number;
20
+ callback?: () => void;
21
+ };
22
+
23
+ const ensureDelay = (value?: number): number =>
24
+ typeof value === "number" ? value : DEFAULT_CLOSE_DELAY;
25
+
26
+ type UseModalReturn = {
27
+ modalStacks: ModalState[];
28
+ newModal: (newStack: ModalState) => void;
29
+ updateModal: (nextStack: ModalStatePatch) => void;
30
+ closeModal: (request: ModalCloseRequest) => void;
31
+ };
32
+
33
+ /**
34
+ * ui-legacy 스펙을 최대한 유지한 모달 스택 훅.
35
+ * @hook
36
+ * @returns {UseModalReturn} 스택 상태와 조작자(new/open/close)
37
+ * @desc
38
+ * - modalStacks: 현재 열린 스택 배열
39
+ * - newModal: stackKey 중복 검증 후 "init" 상태로 푸시
40
+ * - updateModal: 부분 업데이트(클래스/슬롯 변경)
41
+ * - closeModal: show=false → delay 후 제거
42
+ */
43
+ export function useModal(): UseModalReturn {
44
+ const [modalStacks, updateModalStack] = useAtom(modalStackAtom);
45
+ const [closeFlag, setCloseFlag] = useState<CloseFlagState | null>(null);
46
+
47
+ const newModal = useCallback(
48
+ (newStack: ModalState) => {
49
+ updateModalStack((stacks: ModalState[]) => {
50
+ const exists = stacks.some(
51
+ stack => stack.stackKey === newStack.stackKey,
52
+ );
53
+ if (exists) {
54
+ console.warn(
55
+ `[useModal] stack "${newStack.stackKey}" already exists; ignored.`,
56
+ );
57
+ return stacks;
58
+ }
59
+ return [
60
+ ...stacks,
61
+ {
62
+ ...newStack,
63
+ modalProps: { ...newStack.modalProps, show: "init" },
64
+ },
65
+ ];
66
+ });
67
+ },
68
+ [updateModalStack],
69
+ );
70
+
71
+ const updateModal = useCallback(
72
+ (nextStack: ModalStatePatch) => {
73
+ updateModalStack((stacks: ModalState[]) =>
74
+ stacks.map(stack => {
75
+ if (stack.stackKey !== nextStack.stackKey) return stack;
76
+ const mergedProps = nextStack.modalProps
77
+ ? { ...stack.modalProps, ...nextStack.modalProps }
78
+ : stack.modalProps;
79
+ return {
80
+ ...stack,
81
+ ...(typeof nextStack.className !== "undefined"
82
+ ? { className: nextStack.className }
83
+ : null),
84
+ modalProps: mergedProps,
85
+ };
86
+ }),
87
+ );
88
+ },
89
+ [updateModalStack],
90
+ );
91
+
92
+ const closeModal = useCallback(
93
+ ({ stackKey, callback }: ModalCloseRequest) => {
94
+ let resolvedDelay = DEFAULT_CLOSE_DELAY;
95
+ let hasTarget = false;
96
+
97
+ updateModalStack((stacks: ModalState[]) =>
98
+ stacks.map(stack => {
99
+ if (stack.stackKey !== stackKey) return stack;
100
+ hasTarget = true;
101
+ resolvedDelay = ensureDelay(stack.modalProps.showDelay);
102
+ return {
103
+ ...stack,
104
+ modalProps: { ...stack.modalProps, show: false },
105
+ };
106
+ }),
107
+ );
108
+
109
+ if (!hasTarget) {
110
+ console.warn(
111
+ `[useModal] stack "${stackKey}" not found; close skipped.`,
112
+ );
113
+ return;
114
+ }
115
+
116
+ setCloseFlag({ stackKey, showDelay: resolvedDelay, callback });
117
+ },
118
+ [updateModalStack],
119
+ );
120
+
121
+ useEffect(() => {
122
+ if (!closeFlag) return;
123
+
124
+ const timer = setTimeout(() => {
125
+ updateModalStack((stacks: ModalState[]) =>
126
+ stacks.filter(stack => stack.stackKey !== closeFlag.stackKey),
127
+ );
128
+ closeFlag.callback?.();
129
+ setCloseFlag(null);
130
+ }, closeFlag.showDelay);
131
+
132
+ return () => clearTimeout(timer);
133
+ }, [closeFlag, updateModalStack]);
134
+
135
+ return { modalStacks, newModal, updateModal, closeModal };
136
+ }
@@ -0,0 +1,10 @@
1
+ import { atom } from "jotai";
2
+
3
+ import type { ModalState } from "../../types";
4
+
5
+ /**
6
+ * 모달 스택 상태
7
+ * @state
8
+ * @desc ui-legacy 스택 정책을 유지한다.
9
+ */
10
+ export const modalStackAtom = atom<ModalState[]>([]);
@@ -0,0 +1,4 @@
1
+ @use "./styles/base.scss" as *;
2
+ @use "./styles/dimmer.scss" as *;
3
+ @use "./styles/container.scss" as *;
4
+ @use "./styles/animations.scss" as *;
@@ -0,0 +1,16 @@
1
+ import "./index.scss";
2
+
3
+ import { ModalProvider } from "./core/components/Provider";
4
+ import { useModal } from "./core/hooks/useModal";
5
+ import { createAlertModal } from "./templates/Alert";
6
+ import { createDialogModal } from "./templates/Dialog";
7
+
8
+ export const Modal = {
9
+ Provider: ModalProvider,
10
+ useModal,
11
+ Alert: createAlertModal,
12
+ Dialog: createDialogModal,
13
+ };
14
+
15
+ export { ModalProvider, useModal, createAlertModal, createDialogModal };
16
+ export type * from "./types";
@@ -0,0 +1,24 @@
1
+ .uds-modal-surface {
2
+ opacity: 0;
3
+ transform: translate3d(0, 12px, 0);
4
+ transition:
5
+ opacity 0.2s ease,
6
+ transform 0.2s ease;
7
+
8
+ .uds-modal-root[data-state="open"] & {
9
+ opacity: 1;
10
+ transform: translate3d(0, 0, 0);
11
+ }
12
+
13
+ .uds-modal-root[data-state="closed"] & {
14
+ opacity: 0;
15
+ transform: translate3d(0, 12px, 0);
16
+ pointer-events: none;
17
+ }
18
+ }
19
+
20
+ @media (prefers-reduced-motion: reduce) {
21
+ .uds-modal-surface {
22
+ transition: none;
23
+ }
24
+ }
@@ -0,0 +1,45 @@
1
+ @use "@uniai-fe/uds-foundation/css";
2
+
3
+ :where(.radix-themes, .theme-root, :root) {
4
+ --uds-modal-overlay-bg: var(--dialog-overlay-bg, rgba(5, 6, 12, 0.55));
5
+ --uds-modal-surface-bg: var(--color-bg-surface-static-white);
6
+ --uds-modal-surface-radius: var(--theme-radius-large-1);
7
+ --uds-modal-surface-shadow: 0px 18px 40px rgba(8, 11, 30, 0.18);
8
+ --uds-modal-max-width: min(
9
+ 360px,
10
+ calc(100vw - var(--spacing-padding-10) * 2)
11
+ );
12
+ --uds-modal-max-height: calc(100vh - var(--spacing-padding-10) * 2);
13
+ }
14
+
15
+ .uds-modal-root {
16
+ position: fixed;
17
+ inset: 0;
18
+ z-index: calc(400 + var(--uds-modal-index, 0));
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ padding: var(--spacing-padding-6);
23
+ pointer-events: none;
24
+ }
25
+
26
+ .uds-modal-surface {
27
+ position: relative;
28
+ width: 100%;
29
+ max-width: var(--uds-modal-max-width);
30
+ max-height: var(--uds-modal-max-height);
31
+ background-color: var(--uds-modal-surface-bg);
32
+ border-radius: var(--uds-modal-surface-radius);
33
+ box-shadow: var(--uds-modal-surface-shadow);
34
+ pointer-events: auto;
35
+ display: flex;
36
+ flex-direction: column;
37
+ overflow: hidden;
38
+ }
39
+
40
+ .uds-modal-dimmer {
41
+ position: absolute;
42
+ inset: 0;
43
+ background-color: var(--uds-modal-overlay-bg);
44
+ pointer-events: auto;
45
+ }