@uniai-fe/uds-primitives 0.1.13 → 0.2.0

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 (113) hide show
  1. package/README.md +2 -2
  2. package/dist/styles.css +1112 -385
  3. package/package.json +12 -15
  4. package/src/components/button/index.scss +1 -0
  5. package/src/components/button/markup/{ButtonRounded.tsx → Rounded.tsx} +1 -1
  6. package/src/components/button/markup/{ButtonText.tsx → Text.tsx} +1 -1
  7. package/src/components/button/markup/index.ts +3 -3
  8. package/src/components/button/styles/button.scss +113 -229
  9. package/src/components/button/styles/round-button.scss +11 -14
  10. package/src/components/button/styles/text-button.scss +23 -23
  11. package/src/components/button/styles/variables.scss +145 -0
  12. package/src/components/dropdown/index.tsx +3 -3
  13. package/src/components/dropdown/markup/Template.tsx +61 -0
  14. package/src/components/dropdown/markup/foundation/Container.tsx +97 -0
  15. package/src/components/dropdown/markup/foundation/MenuItem.tsx +107 -0
  16. package/src/components/dropdown/markup/foundation/MenuList.tsx +27 -0
  17. package/src/components/dropdown/markup/foundation/Provider.tsx +46 -0
  18. package/src/components/dropdown/markup/foundation/Root.tsx +30 -0
  19. package/src/components/dropdown/markup/foundation/Trigger.tsx +34 -0
  20. package/src/components/dropdown/markup/foundation/index.tsx +25 -0
  21. package/src/components/dropdown/markup/index.tsx +8 -2
  22. package/src/components/dropdown/styles/dropdown.scss +166 -0
  23. package/src/components/dropdown/styles/index.scss +2 -0
  24. package/src/components/dropdown/styles/variables.scss +40 -0
  25. package/src/components/dropdown/types/base.ts +18 -0
  26. package/src/components/dropdown/types/index.ts +2 -4
  27. package/src/components/dropdown/types/props.ts +174 -0
  28. package/src/components/dropdown/utils/index.ts +1 -4
  29. package/src/components/dropdown/utils/refs.ts +20 -0
  30. package/src/components/form/index.scss +1 -0
  31. package/src/components/form/index.tsx +18 -2
  32. package/src/components/form/markup/form-field/Body.tsx +18 -0
  33. package/src/components/form/markup/form-field/Container.tsx +58 -0
  34. package/src/components/form/markup/form-field/Footer.tsx +21 -0
  35. package/src/components/form/markup/form-field/Header.tsx +39 -0
  36. package/src/components/form/markup/form-field/Template.tsx +56 -0
  37. package/src/components/form/markup/form-field/index.tsx +22 -0
  38. package/src/components/form/styles/form-field/layout.scss +67 -0
  39. package/src/components/form/styles/form-field/variables.scss +17 -0
  40. package/src/components/form/styles/index.scss +2 -0
  41. package/src/components/form/types/index.ts +1 -0
  42. package/src/components/form/types/props.ts +125 -0
  43. package/src/components/form/utils/form-field.ts +42 -0
  44. package/src/components/input/hooks/index.ts +1 -4
  45. package/src/components/input/hooks/useDigitField.ts +63 -0
  46. package/src/components/input/img/calendar/calendar.svg +7 -0
  47. package/src/components/input/img/calendar/chevron-down.svg +3 -0
  48. package/src/components/input/img/calendar/chevron-left.svg +3 -0
  49. package/src/components/input/img/calendar/chevron-right.svg +3 -0
  50. package/src/components/input/img/calendar/chevron-up.svg +3 -0
  51. package/src/components/input/index.tsx +2 -1
  52. package/src/components/input/markup/calendar/Base.tsx +329 -0
  53. package/src/components/input/markup/calendar/index.tsx +8 -0
  54. package/src/components/input/markup/{text/InputUtilityButton.tsx → foundation/Button.tsx} +5 -15
  55. package/src/components/input/markup/foundation/Input.tsx +245 -0
  56. package/src/components/input/markup/foundation/SideSlot.tsx +30 -0
  57. package/src/components/input/markup/foundation/StatusIcon.tsx +21 -0
  58. package/src/components/input/markup/foundation/Utility.tsx +103 -0
  59. package/src/components/input/markup/foundation/index.tsx +15 -0
  60. package/src/components/input/markup/index.tsx +11 -1
  61. package/src/components/input/markup/text/AuthCode.tsx +41 -59
  62. package/src/components/input/markup/text/Email.tsx +25 -115
  63. package/src/components/input/markup/text/Password.tsx +30 -39
  64. package/src/components/input/markup/text/Phone.tsx +35 -122
  65. package/src/components/input/markup/text/Search.tsx +17 -18
  66. package/src/components/input/markup/text/index.ts +15 -12
  67. package/src/components/input/styles/calendar.scss +110 -0
  68. package/src/components/input/styles/foundation.scss +345 -0
  69. package/src/components/input/styles/index.scss +4 -476
  70. package/src/components/input/styles/text.scss +89 -0
  71. package/src/components/input/styles/variables.scss +41 -0
  72. package/src/components/input/types/calendar.ts +208 -0
  73. package/src/components/input/types/foundation.ts +194 -0
  74. package/src/components/input/types/hooks.ts +43 -0
  75. package/src/components/input/types/index.ts +5 -87
  76. package/src/components/input/types/text.ts +203 -0
  77. package/src/components/input/types/verification.ts +23 -0
  78. package/src/components/input/utils/index.tsx +1 -0
  79. package/src/components/input/utils/verification.tsx +35 -0
  80. package/src/components/select/hooks/index.ts +43 -2
  81. package/src/components/select/img/chevron/primary/large.svg +3 -0
  82. package/src/components/select/img/chevron/primary/medium.svg +3 -0
  83. package/src/components/select/img/chevron/primary/small.svg +3 -0
  84. package/src/components/select/img/chevron/secondary/large.svg +3 -0
  85. package/src/components/select/img/chevron/secondary/medium.svg +3 -0
  86. package/src/components/select/img/chevron/secondary/small.svg +3 -0
  87. package/src/components/select/img/remove.svg +3 -0
  88. package/src/components/select/index.scss +2 -1
  89. package/src/components/select/index.tsx +5 -0
  90. package/src/components/select/markup/Default.tsx +154 -0
  91. package/src/components/select/markup/foundation/Base.tsx +90 -0
  92. package/src/components/select/markup/foundation/Container.tsx +30 -0
  93. package/src/components/select/markup/foundation/Icon.tsx +78 -0
  94. package/src/components/select/markup/foundation/Selected.tsx +34 -0
  95. package/src/components/select/markup/foundation/index.ts +2 -0
  96. package/src/components/select/markup/index.tsx +36 -2
  97. package/src/components/select/markup/multiple/Multiple.tsx +205 -0
  98. package/src/components/select/markup/multiple/SelectedChip.tsx +58 -0
  99. package/src/components/select/markup/multiple/index.ts +2 -0
  100. package/src/components/select/styles/select.scss +316 -0
  101. package/src/components/select/styles/variables.scss +91 -0
  102. package/src/components/select/types/base.ts +34 -0
  103. package/src/components/select/types/icon.ts +45 -0
  104. package/src/components/select/types/index.ts +5 -4
  105. package/src/components/select/types/multiple.ts +57 -0
  106. package/src/components/select/types/props.ts +208 -0
  107. package/src/components/select/types/trigger.ts +196 -0
  108. package/src/index.scss +3 -2
  109. package/src/components/input/markup/text/Base.tsx +0 -454
  110. package/src/components/input/utils/index.ts +0 -60
  111. package/src/components/select/styles/index.scss +0 -0
  112. /package/src/components/button/markup/{ButtonDefault.tsx → Base.tsx} +0 -0
  113. /package/src/components/form/{Provider.tsx → markup/Provider.tsx} +0 -0
@@ -0,0 +1,329 @@
1
+ "use client";
2
+
3
+ import {
4
+ DatePicker,
5
+ type DatePickerStylesNames,
6
+ type DatePickerType,
7
+ } from "@mantine/dates";
8
+ import { useUncontrolled } from "@mantine/hooks";
9
+ import type { ChangeEvent, FocusEvent } from "react";
10
+ import { forwardRef, useCallback, useMemo } from "react";
11
+ import { Button } from "../../../button";
12
+ import ChevronLeftIcon from "../../img/calendar/chevron-left.svg";
13
+ import ChevronRightIcon from "../../img/calendar/chevron-right.svg";
14
+ import type {
15
+ InputCalendarActionButtonProps,
16
+ InputCalendarProps,
17
+ InputCalendarValue,
18
+ } from "../../types";
19
+ import { dayjs } from "../../../../init/dayjs";
20
+
21
+ const DATE_FORMAT = "YYYY-MM-DD";
22
+ type CalendarValue = InputCalendarValue<DatePickerType>;
23
+
24
+ const DATE_PICKER_CLASS_NAMES = {
25
+ calendar: "input-calendar-panel",
26
+ datePickerRoot: "input-calendar-root",
27
+ calendarHeader: "input-calendar-header",
28
+ calendarHeaderControl: "input-calendar-header-control",
29
+ calendarHeaderLevel: "input-calendar-header-level",
30
+ day: "input-calendar-day",
31
+ weekday: "input-calendar-weekday",
32
+ weekdaysRow: "input-calendar-weekdays",
33
+ monthRow: "input-calendar-month-row",
34
+ monthCell: "input-calendar-month-cell",
35
+ month: "input-calendar-month",
36
+ monthThead: "input-calendar-month-thead",
37
+ monthTbody: "input-calendar-month-tbody",
38
+ } as Partial<Record<DatePickerStylesNames, string>>;
39
+
40
+ const DEFAULT_ACTION_LABELS: Record<"clear" | "today" | "apply", string> = {
41
+ clear: "삭제",
42
+ today: "오늘",
43
+ apply: "적용하기",
44
+ };
45
+
46
+ const createEmptyValue = (type: DatePickerType): CalendarValue => {
47
+ if (type === "range") {
48
+ return [null, null];
49
+ }
50
+ if (type === "multiple") {
51
+ return [];
52
+ }
53
+ return null;
54
+ };
55
+
56
+ const serializeValue = (value: CalendarValue, type: DatePickerType) => {
57
+ if (type === "range" || type === "multiple") {
58
+ return JSON.stringify(value ?? createEmptyValue(type));
59
+ }
60
+ return (value as string) ?? "";
61
+ };
62
+
63
+ const formatDate = (date: Date | string | null) =>
64
+ date ? dayjs(date).format(DATE_FORMAT) : null;
65
+
66
+ const mapValueToPicker = (value: CalendarValue, type: DatePickerType) => {
67
+ if (type === "range") {
68
+ const [start, end] = (value ?? [null, null]) as InputCalendarValue<"range">;
69
+ return [
70
+ start ? dayjs(start).toDate() : null,
71
+ end ? dayjs(end).toDate() : null,
72
+ ];
73
+ }
74
+ if (type === "multiple") {
75
+ return ((value ?? []) as InputCalendarValue<"multiple">).map(entry =>
76
+ entry ? dayjs(entry).toDate() : null,
77
+ );
78
+ }
79
+ if (typeof value === "string" && value) {
80
+ return dayjs(value).toDate();
81
+ }
82
+ return null;
83
+ };
84
+
85
+ const parseValueFromPicker = (
86
+ value: unknown,
87
+ type: DatePickerType,
88
+ ): CalendarValue => {
89
+ if (type === "range") {
90
+ const [start, end] = (value as [Date | null, Date | null]) ?? [null, null];
91
+ return [formatDate(start), formatDate(end)] as CalendarValue;
92
+ }
93
+ if (type === "multiple") {
94
+ return ((value as (Date | null)[]) || []).map(entry =>
95
+ formatDate(entry),
96
+ ) as CalendarValue;
97
+ }
98
+ return formatDate((value as Date | null) ?? null);
99
+ };
100
+
101
+ const getTodayValue = (type: DatePickerType): CalendarValue => {
102
+ const today = dayjs().format(DATE_FORMAT);
103
+ if (type === "range") {
104
+ return [today, today];
105
+ }
106
+ if (type === "multiple") {
107
+ return [today];
108
+ }
109
+ return today;
110
+ };
111
+
112
+ const resolveAction = (
113
+ action: InputCalendarActionButtonProps | undefined,
114
+ key: keyof typeof DEFAULT_ACTION_LABELS,
115
+ ) => {
116
+ const visible = action?.visible ?? true;
117
+ if (!visible) return null;
118
+ return {
119
+ label: action?.label ?? DEFAULT_ACTION_LABELS[key],
120
+ disabled: action?.disabled ?? false,
121
+ };
122
+ };
123
+
124
+ const InputCalendarBase = forwardRef<
125
+ HTMLDivElement,
126
+ InputCalendarProps<DatePickerType>
127
+ >(
128
+ // DatePickerType 유니언을 명시해 range/multiple 비교 로직 타입 오류를 방지한다.
129
+ (props, ref) => {
130
+ const {
131
+ mode = "date",
132
+ type = "default",
133
+ columns = 1,
134
+ value,
135
+ defaultValue,
136
+ onChange,
137
+ onValueChange,
138
+ onClear,
139
+ onToday,
140
+ onApply,
141
+ readOnly,
142
+ disabled,
143
+ datePickerProps,
144
+ actions,
145
+ name,
146
+ register,
147
+ } = props;
148
+
149
+ const [calendarValue, setCalendarValue] = useUncontrolled<CalendarValue>({
150
+ value,
151
+ defaultValue,
152
+ finalValue: createEmptyValue(type),
153
+ onChange: changedValue => {
154
+ onChange?.(changedValue as InputCalendarValue<typeof type>);
155
+ onValueChange?.(changedValue as InputCalendarValue<typeof type>);
156
+ },
157
+ });
158
+
159
+ const serializedValue = useMemo(
160
+ () => serializeValue(calendarValue, type),
161
+ [calendarValue, type],
162
+ );
163
+
164
+ const emitRegisterChange = useCallback(
165
+ (nextValue: CalendarValue) => {
166
+ if (!register?.onChange) {
167
+ return;
168
+ }
169
+
170
+ const syntheticEvent = {
171
+ target: {
172
+ name: register.name,
173
+ value: serializeValue(nextValue, type),
174
+ },
175
+ currentTarget: {
176
+ name: register.name,
177
+ value: serializeValue(nextValue, type),
178
+ },
179
+ } as unknown as ChangeEvent<HTMLInputElement>;
180
+
181
+ register.onChange(syntheticEvent);
182
+ },
183
+ [register, type],
184
+ );
185
+
186
+ const updateValue = useCallback(
187
+ (nextValue: CalendarValue) => {
188
+ setCalendarValue(nextValue as InputCalendarValue<typeof type>);
189
+ emitRegisterChange(nextValue);
190
+ },
191
+ [emitRegisterChange, setCalendarValue],
192
+ );
193
+
194
+ const handleDateChange = useCallback(
195
+ (next: unknown) => {
196
+ const normalized = parseValueFromPicker(next, type);
197
+ updateValue(normalized);
198
+ },
199
+ [type, updateValue],
200
+ );
201
+
202
+ const handleClear = () => {
203
+ updateValue(createEmptyValue(type));
204
+ onClear?.();
205
+ };
206
+
207
+ const handleToday = () => {
208
+ updateValue(getTodayValue(type));
209
+ onToday?.();
210
+ };
211
+
212
+ const handleApply = () => {
213
+ onApply?.(calendarValue as InputCalendarValue<typeof type>);
214
+ };
215
+
216
+ const resolvedClear = resolveAction(actions?.clear, "clear");
217
+ const resolvedToday = resolveAction(actions?.today, "today");
218
+ const resolvedApply = resolveAction(actions?.apply, "apply");
219
+
220
+ const isInteractive = !(readOnly || disabled);
221
+ const hiddenName = register?.name ?? name;
222
+
223
+ const handleHiddenRef = useCallback(
224
+ (node: HTMLInputElement | null) => {
225
+ if (register?.ref) {
226
+ register.ref(node);
227
+ }
228
+ },
229
+ [register],
230
+ );
231
+
232
+ const handleHiddenBlur = (event: FocusEvent<HTMLInputElement>) => {
233
+ register?.onBlur?.(event);
234
+ };
235
+
236
+ if (mode !== "date" && process.env.NODE_ENV !== "production") {
237
+ console.warn("InputCalendarBase: 현재 date 모드만 지원합니다.");
238
+ }
239
+
240
+ const singleRangeProps =
241
+ type === "range"
242
+ ? {
243
+ allowSingleDateInRange: true,
244
+ }
245
+ : {};
246
+
247
+ return (
248
+ <div
249
+ ref={ref}
250
+ className="input-calendar"
251
+ data-mode={mode}
252
+ data-type={type}
253
+ data-columns={columns}
254
+ data-disabled={disabled ? "true" : undefined}
255
+ >
256
+ {hiddenName ? (
257
+ <input
258
+ type="hidden"
259
+ name={hiddenName}
260
+ value={serializedValue}
261
+ readOnly
262
+ ref={handleHiddenRef}
263
+ onBlur={handleHiddenBlur}
264
+ />
265
+ ) : null}
266
+ <div className="input-calendar-container">
267
+ <DatePicker
268
+ classNames={DATE_PICKER_CLASS_NAMES}
269
+ value={mapValueToPicker(calendarValue, type) as never}
270
+ onChange={handleDateChange as never}
271
+ type={type}
272
+ numberOfColumns={columns}
273
+ nextIcon={<ChevronRightIcon />}
274
+ previousIcon={<ChevronLeftIcon />}
275
+ // Mantine DatePicker에는 disabled prop이 없어 data-disabled로 상단에서 제어한다.
276
+ {...(datePickerProps ?? {})}
277
+ {...singleRangeProps}
278
+ />
279
+ <div className="input-calendar-footer">
280
+ <div className="input-calendar-footer-actions">
281
+ {resolvedClear ? (
282
+ <Button.Default
283
+ fill="outlined"
284
+ size="small"
285
+ priority="tertiary"
286
+ disabled={!isInteractive || resolvedClear.disabled}
287
+ onClick={handleClear}
288
+ className="input-calendar-action-button"
289
+ >
290
+ {resolvedClear.label}
291
+ </Button.Default>
292
+ ) : (
293
+ <span />
294
+ )}
295
+ {resolvedToday ? (
296
+ <Button.Default
297
+ fill="outlined"
298
+ size="small"
299
+ priority="secondary"
300
+ disabled={!isInteractive || resolvedToday.disabled}
301
+ onClick={handleToday}
302
+ className="input-calendar-action-button"
303
+ >
304
+ {resolvedToday.label}
305
+ </Button.Default>
306
+ ) : null}
307
+ </div>
308
+ {resolvedApply ? (
309
+ <Button.Default
310
+ fill="solid"
311
+ size="large"
312
+ priority="primary"
313
+ disabled={!isInteractive || resolvedApply.disabled}
314
+ onClick={handleApply}
315
+ className="input-calendar-apply"
316
+ >
317
+ {resolvedApply.label}
318
+ </Button.Default>
319
+ ) : null}
320
+ </div>
321
+ </div>
322
+ </div>
323
+ );
324
+ },
325
+ );
326
+
327
+ InputCalendarBase.displayName = "InputCalendarBase";
328
+
329
+ export { InputCalendarBase };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import "../../styles/calendar.scss";
4
+ import { InputCalendarBase } from "./Base";
5
+
6
+ export const InputCalendar = {
7
+ Base: InputCalendarBase,
8
+ };
@@ -1,17 +1,8 @@
1
- import type { MouseEvent, ReactNode } from "react";
2
- import { Button } from "../../../button";
3
- import type { ButtonPriority, ButtonProps } from "../../../button/types";
4
-
5
- export type InputUtilityButtonClickHandler = (
6
- event?: MouseEvent<HTMLButtonElement> | MouseEvent<HTMLAnchorElement>,
7
- ) => void;
1
+ "use client";
8
2
 
9
- interface InputUtilityButtonProps {
10
- children: ReactNode;
11
- priority?: ButtonPriority;
12
- disabled?: boolean;
13
- onClick?: InputUtilityButtonClickHandler;
14
- }
3
+ import { Button } from "../../../button";
4
+ import type { ButtonProps } from "../../../button/types";
5
+ import type { InputUtilityButtonProps } from "../../types";
15
6
 
16
7
  /**
17
8
  * 입력 필드 우측에 배치되는 유틸리티 버튼; outlined-small scale을 공통으로 사용한다.
@@ -22,7 +13,7 @@ interface InputUtilityButtonProps {
22
13
  * @param {boolean} [props.disabled] disabled 여부
23
14
  * @param {InputUtilityButtonClickHandler} [props.onClick] onClick 핸들러
24
15
  */
25
- export function InputUtilityButton({
16
+ export default function InputBaseUtilityButton({
26
17
  children,
27
18
  priority = "primary",
28
19
  disabled,
@@ -34,7 +25,6 @@ export function InputUtilityButton({
34
25
 
35
26
  return (
36
27
  <Button.Default
37
- scale="outlined-small"
38
28
  fill="outlined"
39
29
  size="small"
40
30
  priority={priority}
@@ -0,0 +1,245 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import type { PointerEvent as ReactPointerEvent } from "react";
5
+ import {
6
+ ChangeEvent,
7
+ FocusEvent,
8
+ MouseEvent,
9
+ forwardRef,
10
+ useCallback,
11
+ useEffect,
12
+ useId,
13
+ useRef,
14
+ useState,
15
+ } from "react";
16
+ import type { InputProps } from "../../types";
17
+
18
+ import InputBaseSideSlot from "./SideSlot";
19
+ import InputBaseUtil from "./Utility";
20
+
21
+ /**
22
+ * Native `<input>` 기반 텍스트 필드.
23
+ * priority/size/state 축과 left/right/clear/status 슬롯을 제공하며
24
+ * react-hook-form `register` 결과를 그대로 전달받아 내부 ref/onChange/onBlur에 병합한다.
25
+ *
26
+ * @component
27
+ * @param {InputProps} props Input 컴포넌트 공통 props
28
+ * @param {"primary" | "secondary" | "tertiary"} [props.priority="primary"] 디자인 토큰 우선순위
29
+ * @param {"small" | "medium" | "large"} [props.size="medium"] 높이/타이포 세트
30
+ * @param {"default" | "active" | "focused" | "success" | "error" | "disabled"} [props.state="default"] 시각 상태
31
+ * @param {boolean} [props.block=false] true면 width 100%
32
+ * @param {React.ReactNode} [props.left] 입력 왼쪽 슬롯(아이콘/텍스트)
33
+ * @param {React.ReactNode} [props.right] 입력 오른쪽 슬롯
34
+ * @param {React.ReactNode} [props.clearIcon] 입력값 초기화 아이콘. 지정하지 않으면 기본 Reset 아이콘
35
+ * @param {React.ReactNode} [props.successIcon] success 상태 아이콘 override
36
+ * @param {React.ReactNode} [props.errorIcon] error 상태 아이콘 override
37
+ * @param {string} [props.inputClassName] 실제 `<input>` 요소 className
38
+ * @param {string} [props.boxClassName] `.input-box` className
39
+ * @param {boolean} [props.disabled] native disabled
40
+ * @param {string} [props.id] 외부 id. label htmlFor와 공유된다
41
+ * @param {string} [props.className] root `.input` className
42
+ * @param {UseFormRegisterReturn} [props.register] react-hook-form register 반환값
43
+ * @param {string} [props.name] native name. register 사용 시 자동으로 병합
44
+ * @param {InputState} [props.data-simulated-state] Storybook 등에서 시각 상태 강제용
45
+ * @param {(event: ChangeEvent<HTMLInputElement>) => void} [props.onChange] change 핸들러
46
+ * @param {(event: FocusEvent<HTMLInputElement>) => void} [props.onFocus] focus 핸들러
47
+ * @param {(event: FocusEvent<HTMLInputElement>) => void} [props.onBlur] blur 핸들러
48
+ * @param {string | number | readonly string[]} [props.value] 제어형 값
49
+ * @param {string | number | readonly string[]} [props.defaultValue] 비제어 초기값
50
+ * @param {string} [props.type="text"] native input type
51
+ */
52
+ const InputBase = forwardRef<HTMLInputElement, InputProps>(
53
+ (
54
+ {
55
+ priority = "primary",
56
+ size = "medium",
57
+ state: stateProp = "default",
58
+ block = false,
59
+ left,
60
+ right,
61
+ clear,
62
+ success,
63
+ error,
64
+ inputClassName,
65
+ boxClassName: boxClassNameProp,
66
+ disabled,
67
+ id,
68
+ className,
69
+ register,
70
+ "data-simulated-state": simulatedState,
71
+ value,
72
+ defaultValue,
73
+ name,
74
+ onChange,
75
+ onFocus,
76
+ onBlur,
77
+ type = "text",
78
+ ...restProps
79
+ },
80
+ ref,
81
+ ) => {
82
+ const generatedId = useId();
83
+ const registerRef = register?.ref;
84
+ const registerOnChange = register?.onChange;
85
+ const registerOnBlur = register?.onBlur;
86
+ const registerName = register?.name;
87
+ const inputNativeRef = useRef<HTMLInputElement | null>(null);
88
+ const [isFocused, setIsFocused] = useState(false);
89
+ const [hasValue, setHasValue] = useState(() => {
90
+ const initial = value ?? defaultValue;
91
+ return initial !== undefined && initial !== null
92
+ ? String(initial).length > 0
93
+ : false;
94
+ });
95
+ useEffect(() => {
96
+ if (disabled || stateProp === "disabled") setIsFocused(false);
97
+ }, [disabled, stateProp]);
98
+
99
+ useEffect(() => {
100
+ if (value !== undefined && value !== null)
101
+ setHasValue(String(value).length > 0);
102
+ }, [value]);
103
+
104
+ const currentState = disabled ? "disabled" : stateProp;
105
+ const isDisabled =
106
+ currentState === "disabled" || currentState === "loading";
107
+ const visualState = !isDisabled && isFocused ? "active" : currentState;
108
+
109
+ const setInputRef = useCallback(
110
+ (node: HTMLInputElement | null) => {
111
+ inputNativeRef.current = node;
112
+ if (typeof ref === "function") ref(node);
113
+ else if (ref) ref.current = node;
114
+
115
+ registerRef?.(node);
116
+ },
117
+ [ref, registerRef],
118
+ );
119
+
120
+ const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
121
+ setIsFocused(true);
122
+ onFocus?.(event);
123
+ };
124
+
125
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
126
+ setIsFocused(false);
127
+ registerOnBlur?.(event);
128
+ onBlur?.(event);
129
+ };
130
+
131
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
132
+ setHasValue(event.currentTarget.value.length > 0);
133
+ registerOnChange?.(event);
134
+ onChange?.(event);
135
+ };
136
+
137
+ const dispatchNativeInputEvent = () => {
138
+ const inputEl = inputNativeRef.current;
139
+ if (!inputEl) {
140
+ return;
141
+ }
142
+ const nativeSetter = Object.getOwnPropertyDescriptor(
143
+ HTMLInputElement.prototype,
144
+ "value",
145
+ )?.set;
146
+ nativeSetter?.call(inputEl, "");
147
+ const changeEvent = new Event("input", { bubbles: true });
148
+ inputEl.dispatchEvent(changeEvent);
149
+ };
150
+
151
+ const handleClear = (
152
+ event:
153
+ | MouseEvent<HTMLButtonElement>
154
+ | ReactPointerEvent<HTMLButtonElement>,
155
+ ) => {
156
+ event.preventDefault();
157
+ dispatchNativeInputEvent();
158
+ setHasValue(false);
159
+ const inputEl = inputNativeRef.current;
160
+ inputEl?.focus();
161
+ // 일부 브라우저(특히 로그인 필드)가 focus 직후 자동완성을 재삽입하는 경우가 있어
162
+ // 다음 프레임에서 한 번 더 초기화해 값을 비워 둔다.
163
+ requestAnimationFrame(() => {
164
+ dispatchNativeInputEvent();
165
+ });
166
+ };
167
+
168
+ const inputName = registerName ?? name;
169
+
170
+ return (
171
+ <div
172
+ className={clsx(
173
+ "input",
174
+ `input-priority-${priority}`,
175
+ `input-size-${size}`,
176
+ `input-state-${visualState}`,
177
+ block && "input-block",
178
+ className,
179
+ )}
180
+ data-priority={priority}
181
+ data-size={size}
182
+ data-state={visualState}
183
+ data-block={block ? "true" : undefined}
184
+ {...(simulatedState ? { "data-simulated-state": simulatedState } : {})}
185
+ >
186
+ <div
187
+ className={clsx(
188
+ "input-box",
189
+ `input-box-priority-${priority}`,
190
+ `input-box-size-${size}`,
191
+ `input-box-state-${visualState}`,
192
+ block && "input-box-block",
193
+ boxClassNameProp,
194
+ )}
195
+ data-slot="box"
196
+ >
197
+ <div
198
+ className="input-field"
199
+ data-state={visualState}
200
+ data-priority={priority}
201
+ data-size={size}
202
+ data-block={block ? "true" : undefined}
203
+ >
204
+ {/* 필드 컨트롤 wrapper; 슬롯과 input 정렬 */}
205
+ <div className="input-field-control">
206
+ {left && (
207
+ <InputBaseSideSlot type="left">{left}</InputBaseSideSlot>
208
+ )}
209
+ <input
210
+ {...restProps}
211
+ id={id ?? generatedId}
212
+ ref={setInputRef}
213
+ className={clsx("input-element", inputClassName)}
214
+ disabled={isDisabled}
215
+ aria-invalid={currentState === "error" ? true : undefined}
216
+ type={type}
217
+ value={value}
218
+ defaultValue={defaultValue}
219
+ name={inputName}
220
+ onChange={handleChange}
221
+ onFocus={handleFocus}
222
+ onBlur={handleBlur}
223
+ />
224
+ </div>
225
+ <InputBaseUtil
226
+ state={currentState}
227
+ isDisabled={isDisabled}
228
+ isFocused={isFocused}
229
+ hasValue={hasValue}
230
+ readOnly={restProps.readOnly}
231
+ onClear={handleClear}
232
+ {...{ clear, success, error }}
233
+ >
234
+ {right}
235
+ </InputBaseUtil>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ );
240
+ },
241
+ );
242
+
243
+ InputBase.displayName = "TextInput";
244
+
245
+ export default InputBase;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+
5
+ /**
6
+ * input; 좌우 슬롯 컴포넌트
7
+ * @component
8
+ * @param {Object} props
9
+ * @param {string} [props.className] 추가 className
10
+ * @param {React.ReactNode} props.children 슬롯 콘텐츠
11
+ * @param {"left" | "right"} props.type 슬롯 타입
12
+ */
13
+ export default function InputBaseSideSlot({
14
+ className,
15
+ children,
16
+ type,
17
+ }: {
18
+ className?: string;
19
+ children: React.ReactNode;
20
+ type: "left" | "right";
21
+ }) {
22
+ return (
23
+ <div
24
+ className={clsx("input-side", `input-side-${type}`, className)}
25
+ data-slot={type}
26
+ >
27
+ {children}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,21 @@
1
+ import ErrorIcon from "../../img/error.svg";
2
+ import ResetIcon from "../../img/reset.svg";
3
+ import SuccessIcon from "../../img/success.svg";
4
+
5
+ const DEFAULT_ATTRS: React.SVGAttributes<SVGElement> = {
6
+ width: 24,
7
+ height: 24,
8
+ "aria-hidden": "true",
9
+ };
10
+
11
+ /**
12
+ * input; 상태 아이콘
13
+ * - error: 에러 상태 아이콘
14
+ * - reset: 리셋 상태 아이콘
15
+ * - success: 성공 상태 아이콘
16
+ */
17
+ export const InputStatusIcon: Record<string, React.ReactNode> = {
18
+ error: <ErrorIcon {...DEFAULT_ATTRS} />,
19
+ reset: <ResetIcon {...DEFAULT_ATTRS} />,
20
+ success: <SuccessIcon {...DEFAULT_ATTRS} />,
21
+ };