@uniai-fe/uds-primitives 0.1.13 → 0.2.1
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.
- package/README.md +2 -2
- package/dist/styles.css +1112 -385
- package/package.json +12 -15
- package/src/components/button/index.scss +1 -0
- package/src/components/button/markup/{ButtonRounded.tsx → Rounded.tsx} +1 -1
- package/src/components/button/markup/{ButtonText.tsx → Text.tsx} +1 -1
- package/src/components/button/markup/index.ts +3 -3
- package/src/components/button/styles/button.scss +113 -229
- package/src/components/button/styles/round-button.scss +11 -14
- package/src/components/button/styles/text-button.scss +23 -23
- package/src/components/button/styles/variables.scss +145 -0
- package/src/components/dropdown/index.tsx +3 -3
- package/src/components/dropdown/markup/Template.tsx +57 -0
- package/src/components/dropdown/markup/foundation/Container.tsx +125 -0
- package/src/components/dropdown/markup/foundation/MenuItem.tsx +107 -0
- package/src/components/dropdown/markup/foundation/MenuList.tsx +27 -0
- package/src/components/dropdown/markup/foundation/Provider.tsx +46 -0
- package/src/components/dropdown/markup/foundation/Root.tsx +30 -0
- package/src/components/dropdown/markup/foundation/Trigger.tsx +34 -0
- package/src/components/dropdown/markup/foundation/index.tsx +25 -0
- package/src/components/dropdown/markup/index.tsx +8 -2
- package/src/components/dropdown/styles/dropdown.scss +166 -0
- package/src/components/dropdown/styles/index.scss +2 -0
- package/src/components/dropdown/styles/variables.scss +40 -0
- package/src/components/dropdown/types/base.ts +31 -0
- package/src/components/dropdown/types/index.ts +2 -4
- package/src/components/dropdown/types/props.ts +170 -0
- package/src/components/dropdown/utils/index.ts +1 -4
- package/src/components/dropdown/utils/refs.ts +20 -0
- package/src/components/form/index.scss +1 -0
- package/src/components/form/index.tsx +18 -2
- package/src/components/form/markup/form-field/Body.tsx +18 -0
- package/src/components/form/markup/form-field/Container.tsx +58 -0
- package/src/components/form/markup/form-field/Footer.tsx +21 -0
- package/src/components/form/markup/form-field/Header.tsx +39 -0
- package/src/components/form/markup/form-field/Template.tsx +56 -0
- package/src/components/form/markup/form-field/index.tsx +22 -0
- package/src/components/form/styles/form-field/layout.scss +67 -0
- package/src/components/form/styles/form-field/variables.scss +17 -0
- package/src/components/form/styles/index.scss +2 -0
- package/src/components/form/types/index.ts +1 -0
- package/src/components/form/types/props.ts +125 -0
- package/src/components/form/utils/form-field.ts +42 -0
- package/src/components/input/hooks/index.ts +1 -4
- package/src/components/input/hooks/useDigitField.ts +63 -0
- package/src/components/input/img/calendar/calendar.svg +7 -0
- package/src/components/input/img/calendar/chevron-down.svg +3 -0
- package/src/components/input/img/calendar/chevron-left.svg +3 -0
- package/src/components/input/img/calendar/chevron-right.svg +3 -0
- package/src/components/input/img/calendar/chevron-up.svg +3 -0
- package/src/components/input/index.tsx +2 -1
- package/src/components/input/markup/calendar/Base.tsx +329 -0
- package/src/components/input/markup/calendar/index.tsx +8 -0
- package/src/components/input/markup/{text/InputUtilityButton.tsx → foundation/Button.tsx} +5 -15
- package/src/components/input/markup/foundation/Input.tsx +245 -0
- package/src/components/input/markup/foundation/SideSlot.tsx +30 -0
- package/src/components/input/markup/foundation/StatusIcon.tsx +21 -0
- package/src/components/input/markup/foundation/Utility.tsx +103 -0
- package/src/components/input/markup/foundation/index.tsx +15 -0
- package/src/components/input/markup/index.tsx +11 -1
- package/src/components/input/markup/text/AuthCode.tsx +41 -59
- package/src/components/input/markup/text/Email.tsx +25 -115
- package/src/components/input/markup/text/Password.tsx +30 -39
- package/src/components/input/markup/text/Phone.tsx +35 -122
- package/src/components/input/markup/text/Search.tsx +17 -18
- package/src/components/input/markup/text/index.ts +15 -12
- package/src/components/input/styles/calendar.scss +110 -0
- package/src/components/input/styles/foundation.scss +345 -0
- package/src/components/input/styles/index.scss +4 -476
- package/src/components/input/styles/text.scss +89 -0
- package/src/components/input/styles/variables.scss +41 -0
- package/src/components/input/types/calendar.ts +208 -0
- package/src/components/input/types/foundation.ts +194 -0
- package/src/components/input/types/hooks.ts +43 -0
- package/src/components/input/types/index.ts +5 -87
- package/src/components/input/types/text.ts +203 -0
- package/src/components/input/types/verification.ts +23 -0
- package/src/components/input/utils/index.tsx +1 -0
- package/src/components/input/utils/verification.tsx +35 -0
- package/src/components/select/hooks/index.ts +43 -2
- package/src/components/select/img/chevron/primary/large.svg +3 -0
- package/src/components/select/img/chevron/primary/medium.svg +3 -0
- package/src/components/select/img/chevron/primary/small.svg +3 -0
- package/src/components/select/img/chevron/secondary/large.svg +3 -0
- package/src/components/select/img/chevron/secondary/medium.svg +3 -0
- package/src/components/select/img/chevron/secondary/small.svg +3 -0
- package/src/components/select/img/remove.svg +3 -0
- package/src/components/select/index.scss +2 -1
- package/src/components/select/index.tsx +5 -0
- package/src/components/select/markup/Default.tsx +154 -0
- package/src/components/select/markup/foundation/Base.tsx +90 -0
- package/src/components/select/markup/foundation/Container.tsx +30 -0
- package/src/components/select/markup/foundation/Icon.tsx +78 -0
- package/src/components/select/markup/foundation/Selected.tsx +34 -0
- package/src/components/select/markup/foundation/index.ts +2 -0
- package/src/components/select/markup/index.tsx +36 -2
- package/src/components/select/markup/multiple/Multiple.tsx +205 -0
- package/src/components/select/markup/multiple/SelectedChip.tsx +58 -0
- package/src/components/select/markup/multiple/index.ts +2 -0
- package/src/components/select/styles/select.scss +316 -0
- package/src/components/select/styles/variables.scss +91 -0
- package/src/components/select/types/base.ts +34 -0
- package/src/components/select/types/icon.ts +45 -0
- package/src/components/select/types/index.ts +6 -4
- package/src/components/select/types/multiple.ts +57 -0
- package/src/components/select/types/option.ts +43 -0
- package/src/components/select/types/props.ts +209 -0
- package/src/components/select/types/trigger.ts +196 -0
- package/src/index.scss +3 -2
- package/src/components/input/markup/text/Base.tsx +0 -454
- package/src/components/input/utils/index.ts +0 -60
- package/src/components/select/styles/index.scss +0 -0
- /package/src/components/button/markup/{ButtonDefault.tsx → Base.tsx} +0 -0
- /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 };
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
+
};
|