@uniai-fe/uds-primitives 0.6.4 → 0.6.6

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.
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ import type { ChangeEvent, MouseEvent as ReactMouseEvent } from "react";
4
+ import { forwardRef, useCallback, useMemo, useState } from "react";
5
+ import { useUncontrolled } from "@mantine/hooks";
6
+ import { Calendar } from "../../../../calendar";
7
+ import type {
8
+ InputCalendarRangeProps,
9
+ InputCalendarTriggerRenderProps,
10
+ } from "../../../types";
11
+ import type { CalendarRangeValue, CalendarValue } from "../../../../calendar";
12
+ import {
13
+ createEmptyRangeValue,
14
+ formatRangeTriggerValue,
15
+ getTodayValue,
16
+ serializeCalendarValue,
17
+ } from "../../../utils/date";
18
+ import InputDateApplyButton from "../button/ApplyButton";
19
+ import InputDateClearButton from "../button/ClearButton";
20
+ import InputDateTodayButton from "../button/TodayButton";
21
+ import InputDateFooterTemplateContainer from "../footer/Container";
22
+ import InputDateFooterUtilContainer from "../footer/UtilContainer";
23
+ import InputDateTrigger from "../Trigger";
24
+
25
+ const INPUT_DATE_RANGE_TABLE_FORMAT = "YY-MM-DD";
26
+
27
+ /**
28
+ * Input Date Range Template; trigger + Calendar.Range 조합.
29
+ * priority가 table이면 trigger 표시 포맷을 `YY-MM-DD ~ YY-MM-DD`로 맞춘다.
30
+ * form 저장은 start/end hidden input 2개로 분리한다.
31
+ * @component
32
+ * @param {InputCalendarRangeProps} props range date props
33
+ * @param {CalendarRangeValue} [props.value] 제어형 range 값
34
+ * @param {CalendarRangeValue} [props.defaultValue] 비제어 range 초기값
35
+ * @param {CalendarRangeOnChange} [props.onChange] range 값 변경 이벤트
36
+ * @param {CalendarRangeOnChange} [props.onValueChange] onChange alias
37
+ * @param {boolean} [props.readOnly] 읽기 전용 여부
38
+ * @param {boolean} [props.disabled] 비활성화 여부
39
+ * @param {CalendarRangeDatePickerProps} [props.datePickerProps] Mantine range DatePicker 옵션
40
+ * @param {string} [props.startName] 시작일 hidden input name
41
+ * @param {string} [props.endName] 종료일 hidden input name
42
+ * @param {UseFormRegisterReturn} [props.startRegister] 시작일 RHF register
43
+ * @param {UseFormRegisterReturn} [props.endRegister] 종료일 RHF register
44
+ * @param {string} [props.placeholder="YYYY-MM-DD ~ YYYY-MM-DD"] placeholder
45
+ * @param {"primary" | "secondary" | "tertiary" | "table"} [props.priority="primary"] trigger input priority
46
+ * @param {"default" | "active" | "focused" | "success" | "error" | "disabled" | "loading"} [props.state="default"] trigger input state
47
+ * @param {ReactNode} [props.header] 패널 header 콘텐츠
48
+ * @param {ReactNode} [props.footer] 패널 footer 콘텐츠
49
+ * @param {boolean} [props.calendarOpened] calendar 열림 제어 상태
50
+ * @param {string} [props.id] trigger id
51
+ * @param {ReactNode} [props.trigger] 커스텀 trigger 슬롯
52
+ * @param {(props: InputCalendarTriggerRenderProps) => ReactNode} [props.renderTrigger] 커스텀 trigger 렌더 함수
53
+ * @param {(event: MouseEvent<Element>) => void} [props.onClick] trigger 클릭 핸들러
54
+ * @example
55
+ * <Input.Date.Range.Template
56
+ * startName="start_date"
57
+ * endName="end_date"
58
+ * value={range}
59
+ * onChange={setRange}
60
+ * />
61
+ */
62
+ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
63
+ (
64
+ {
65
+ value,
66
+ defaultValue,
67
+ onChange,
68
+ onValueChange,
69
+ readOnly,
70
+ disabled,
71
+ datePickerProps,
72
+ startName,
73
+ endName,
74
+ startRegister,
75
+ endRegister,
76
+ placeholder = "YYYY-MM-DD ~ YYYY-MM-DD",
77
+ priority = "primary",
78
+ state = "default",
79
+ header,
80
+ footer,
81
+ calendarOpened,
82
+ onClick: triggerOnClick,
83
+ id,
84
+ trigger,
85
+ renderTrigger,
86
+ },
87
+ ref,
88
+ ) => {
89
+ // 기본(비제어) range calendar open 상태를 관리한다.
90
+ const [internalCalendarOpen, setInternalCalendarOpen] = useState(false);
91
+ const resolvedCalendarOpen = calendarOpened ?? internalCalendarOpen;
92
+
93
+ // Calendar.Range tuple 계약을 그대로 유지하며 제어형/비제어 값을 모두 허용한다.
94
+ const [calendarValue, setCalendarValue] =
95
+ useUncontrolled<CalendarRangeValue>({
96
+ value,
97
+ defaultValue,
98
+ finalValue: createEmptyRangeValue(),
99
+ onChange: next => {
100
+ onChange?.(next as CalendarRangeValue);
101
+ onValueChange?.(next as CalendarRangeValue);
102
+ },
103
+ });
104
+
105
+ // react-hook-form register onChange를 start/end 각각 호출해 hidden input 저장값을 맞춘다.
106
+ const emitRegisterChange = useCallback(
107
+ (nextValue: CalendarRangeValue) => {
108
+ const emit = (
109
+ register: typeof startRegister | typeof endRegister | undefined,
110
+ nextCalendarValue: CalendarValue,
111
+ ) => {
112
+ if (!register?.onChange) {
113
+ return;
114
+ }
115
+
116
+ const serialized = serializeCalendarValue(nextCalendarValue);
117
+ const syntheticEvent = {
118
+ target: {
119
+ name: register.name,
120
+ value: serialized,
121
+ },
122
+ currentTarget: {
123
+ name: register.name,
124
+ value: serialized,
125
+ },
126
+ } as unknown as ChangeEvent<HTMLInputElement>;
127
+
128
+ register.onChange(syntheticEvent);
129
+ };
130
+
131
+ emit(startRegister, nextValue[0]);
132
+ emit(endRegister, nextValue[1]);
133
+ },
134
+ [endRegister, startRegister],
135
+ );
136
+
137
+ const updateValue = useCallback(
138
+ (nextValue: CalendarRangeValue) => {
139
+ setCalendarValue(nextValue);
140
+ emitRegisterChange(nextValue);
141
+ },
142
+ [emitRegisterChange, setCalendarValue],
143
+ );
144
+
145
+ const triggerValue = useMemo(
146
+ () =>
147
+ formatRangeTriggerValue(
148
+ calendarValue,
149
+ priority === "table" ? INPUT_DATE_RANGE_TABLE_FORMAT : undefined,
150
+ ),
151
+ [calendarValue, priority],
152
+ );
153
+ const serializedStartValue = serializeCalendarValue(calendarValue[0]);
154
+ const serializedEndValue = serializeCalendarValue(calendarValue[1]);
155
+
156
+ const handleTriggerClick = (event: ReactMouseEvent<Element>) => {
157
+ triggerOnClick?.(event);
158
+ };
159
+
160
+ const handleCalendarChange = (nextValue: CalendarRangeValue) => {
161
+ updateValue(nextValue);
162
+ };
163
+
164
+ // range 기본 footer는 Today row와 Clear/Apply row를 분리해 Figma range footer 구조를 유지한다.
165
+ const footerContent = footer ?? (
166
+ <InputDateFooterTemplateContainer className="input-date-range-footer-template">
167
+ <InputDateFooterUtilContainer className="input-date-range-today-row">
168
+ <InputDateTodayButton
169
+ disabled={disabled}
170
+ onClick={() => {
171
+ const today = getTodayValue();
172
+ updateValue([today, today]);
173
+ }}
174
+ />
175
+ </InputDateFooterUtilContainer>
176
+ <InputDateFooterUtilContainer className="input-date-range-footer-row">
177
+ <InputDateClearButton
178
+ className="input-date-range-clear-button"
179
+ disabled={disabled}
180
+ onClick={() => updateValue(createEmptyRangeValue())}
181
+ >
182
+ 삭제하기
183
+ </InputDateClearButton>
184
+ {/* 변경: range footer는 삭제/적용을 같은 row에 두고 Apply 폭을 content 기준으로 줄인다. */}
185
+ <InputDateApplyButton
186
+ className="input-date-range-apply-button"
187
+ disabled={disabled}
188
+ onClick={() => setInternalCalendarOpen(false)}
189
+ />
190
+ </InputDateFooterUtilContainer>
191
+ </InputDateFooterTemplateContainer>
192
+ );
193
+
194
+ const triggerRenderProps: InputCalendarTriggerRenderProps = {
195
+ id,
196
+ name: startRegister?.name ?? startName,
197
+ placeholder,
198
+ displayValue: triggerValue,
199
+ disabled,
200
+ readOnly,
201
+ onClick: handleTriggerClick,
202
+ priority,
203
+ state,
204
+ };
205
+
206
+ const triggerNode = trigger ?? renderTrigger?.(triggerRenderProps) ?? (
207
+ <InputDateTrigger
208
+ id={id}
209
+ placeholder={placeholder}
210
+ displayValue={triggerValue}
211
+ disabled={disabled}
212
+ state={state}
213
+ readOnly={readOnly}
214
+ onClick={handleTriggerClick}
215
+ priority={priority}
216
+ />
217
+ );
218
+
219
+ return (
220
+ <>
221
+ <Calendar.Range.Root
222
+ ref={ref}
223
+ className="input-date-field"
224
+ disabled={disabled}
225
+ readOnly={readOnly}
226
+ value={calendarValue}
227
+ onChange={handleCalendarChange}
228
+ datePickerProps={datePickerProps}
229
+ header={header}
230
+ footer={footerContent}
231
+ open={resolvedCalendarOpen}
232
+ onOpenChange={setInternalCalendarOpen}
233
+ >
234
+ {triggerNode}
235
+ </Calendar.Range.Root>
236
+ {(startRegister?.name || startName) && (
237
+ <input
238
+ // 변경: range 시작일 저장값은 trigger 표시와 분리해 hidden input이 담당한다.
239
+ type="hidden"
240
+ value={serializedStartValue}
241
+ name={startRegister?.name ?? startName}
242
+ {...startRegister}
243
+ />
244
+ )}
245
+ {(endRegister?.name || endName) && (
246
+ <input
247
+ // 변경: range 종료일 저장값은 trigger 표시와 분리해 hidden input이 담당한다.
248
+ type="hidden"
249
+ value={serializedEndValue}
250
+ name={endRegister?.name ?? endName}
251
+ {...endRegister}
252
+ />
253
+ )}
254
+ </>
255
+ );
256
+ },
257
+ );
258
+
259
+ InputDateRangeTemplate.displayName = "InputDateRangeTemplate";
260
+
261
+ export default InputDateRangeTemplate;
@@ -64,3 +64,47 @@
64
64
  .input-date-apply-button {
65
65
  width: 100%;
66
66
  }
67
+
68
+ .input-date-range-footer-template {
69
+ gap: var(--spacing-gap-6);
70
+ }
71
+
72
+ .input-date-range-today-row {
73
+ justify-content: flex-end;
74
+ }
75
+
76
+ .input-date-range-footer-row {
77
+ justify-content: space-between;
78
+ }
79
+
80
+ .input-date-range-clear-button {
81
+ // range footer의 삭제 액션은 Figma Button/Text node처럼 border 없는 error text로 표현한다.
82
+ min-height: 20px;
83
+ padding-inline: var(--spacing-padding-5);
84
+ border-color: transparent;
85
+ background-color: transparent;
86
+ color: var(--color-feedback-error);
87
+
88
+ &:hover:not(:disabled):not([aria-disabled="true"]),
89
+ &:active:not(:disabled):not([aria-disabled="true"]) {
90
+ border-color: transparent;
91
+ background-color: transparent;
92
+ color: var(--color-feedback-error);
93
+ box-shadow: none;
94
+ }
95
+
96
+ .button-label {
97
+ font-size: var(--font-body-xxsmall-size);
98
+ line-height: 1.4;
99
+ font-weight: var(--font-body-medium-weight);
100
+ letter-spacing: 0;
101
+ }
102
+ }
103
+
104
+ .input-date-range-apply-button {
105
+ // range footer의 적용 버튼은 full width가 아니라 Figma CTA 크기처럼 content 폭만 사용한다.
106
+ width: fit-content;
107
+ flex: 0 0 auto;
108
+ --button-width: fit-content;
109
+ --button-flex: 0 0 auto;
110
+ }
@@ -5,6 +5,9 @@ import type {
5
5
  CalendarDatePickerProps,
6
6
  CalendarMode,
7
7
  CalendarOnChange,
8
+ CalendarRangeDatePickerProps,
9
+ CalendarRangeOnChange,
10
+ CalendarRangeValue,
8
11
  CalendarValue,
9
12
  } from "../../calendar";
10
13
  import type { InputPriority, InputState } from "./foundation";
@@ -147,7 +150,6 @@ export interface InputCalendarTriggerRenderProps {
147
150
  * @property {ReactNode} [footer] 커스텀 footer 콘텐츠
148
151
  * @property {unknown} [timePicker] TimePicker 확장용 예약 슬롯(현재 미구현)
149
152
  * @property {boolean} [calendarOpened] calendar 열림 제어 여부
150
- * @property {string} [className] root className
151
153
  * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
152
154
  * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
153
155
  */
@@ -207,9 +209,114 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
207
209
  */
208
210
  calendarOpened?: boolean;
209
211
  /**
210
- * root className
212
+ * 커스텀 trigger 슬롯
211
213
  */
212
- className?: string;
214
+ trigger?: ReactNode;
215
+ /**
216
+ * 커스텀 trigger 렌더 함수
217
+ */
218
+ renderTrigger?: (props: InputCalendarTriggerRenderProps) => ReactNode;
219
+ }
220
+
221
+ /**
222
+ * Input Calendar Range Template props.
223
+ * @property {CalendarRangeValue} [value] 제어형 range 값
224
+ * @property {CalendarRangeValue} [defaultValue] 비제어 range 초기값
225
+ * @property {CalendarRangeOnChange} [onChange] range 값 변경 핸들러
226
+ * @property {CalendarRangeOnChange} [onValueChange] onChange 별칭
227
+ * @property {boolean} [readOnly] 읽기 전용 여부
228
+ * @property {boolean} [disabled] disabled 여부
229
+ * @property {CalendarRangeDatePickerProps} [datePickerProps] Mantine range DatePicker 옵션
230
+ * @property {string} [startName] 시작일 hidden input name
231
+ * @property {string} [endName] 종료일 hidden input name
232
+ * @property {UseFormRegisterReturn} [startRegister] 시작일 react-hook-form register 결과
233
+ * @property {UseFormRegisterReturn} [endRegister] 종료일 react-hook-form register 결과
234
+ * @property {string} [placeholder] trigger 내부 placeholder
235
+ * @property {InputPriority} [priority] trigger input priority
236
+ * @property {InputState} [state] trigger input state
237
+ * @property {ReactNode} [header] 커스텀 header 콘텐츠
238
+ * @property {ReactNode} [footer] 커스텀 footer 콘텐츠
239
+ * @property {boolean} [calendarOpened] calendar 열림 제어 여부
240
+ * @property {string} [id] trigger id
241
+ * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
242
+ * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
243
+ * @property {(event: MouseEvent<Element>) => void} [onClick] trigger 클릭 핸들러
244
+ */
245
+ export interface InputCalendarRangeProps {
246
+ /**
247
+ * 제어형 range 값
248
+ */
249
+ value?: CalendarRangeValue;
250
+ /**
251
+ * 비제어 range 초기값
252
+ */
253
+ defaultValue?: CalendarRangeValue;
254
+ /**
255
+ * range 값 변경 핸들러
256
+ */
257
+ onChange?: CalendarRangeOnChange;
258
+ /**
259
+ * onChange 별칭; 외부 파이프라인 시 사용
260
+ */
261
+ onValueChange?: CalendarRangeOnChange;
262
+ /**
263
+ * 읽기 전용 여부
264
+ */
265
+ readOnly?: boolean;
266
+ /**
267
+ * disabled 여부
268
+ */
269
+ disabled?: boolean;
270
+ /**
271
+ * Mantine range DatePicker 옵션
272
+ */
273
+ datePickerProps?: CalendarRangeDatePickerProps;
274
+ /**
275
+ * 시작일 hidden input name
276
+ */
277
+ startName?: string;
278
+ /**
279
+ * 종료일 hidden input name
280
+ */
281
+ endName?: string;
282
+ /**
283
+ * 시작일 react-hook-form register 결과
284
+ */
285
+ startRegister?: UseFormRegisterReturn;
286
+ /**
287
+ * 종료일 react-hook-form register 결과
288
+ */
289
+ endRegister?: UseFormRegisterReturn;
290
+ /**
291
+ * trigger 내부 placeholder
292
+ */
293
+ placeholder?: string;
294
+ /**
295
+ * trigger input priority
296
+ * - table일 때 Trigger는 left icon 배치를 사용한다.
297
+ */
298
+ priority?: InputPriority;
299
+ /**
300
+ * trigger input state
301
+ */
302
+ state?: InputState;
303
+ /**
304
+ * 커스텀 header 콘텐츠
305
+ */
306
+ header?: ReactNode;
307
+ /**
308
+ * 커스텀 footer 콘텐츠
309
+ */
310
+ footer?: ReactNode;
311
+ /**
312
+ * calendar 열림 제어 여부
313
+ * - 지정되면 제어형(open state controlled)으로 동작한다.
314
+ */
315
+ calendarOpened?: boolean;
316
+ /**
317
+ * trigger id
318
+ */
319
+ id?: string;
213
320
  /**
214
321
  * 커스텀 trigger 슬롯
215
322
  */
@@ -218,6 +325,10 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
218
325
  * 커스텀 trigger 렌더 함수
219
326
  */
220
327
  renderTrigger?: (props: InputCalendarTriggerRenderProps) => ReactNode;
328
+ /**
329
+ * trigger 클릭 핸들러
330
+ */
331
+ onClick?: (event: MouseEvent<Element>) => void;
221
332
  }
222
333
 
223
334
  /**
@@ -1,5 +1,9 @@
1
1
  import { dayjs } from "../../../init/dayjs";
2
- import type { CalendarColumns, CalendarValue } from "../../calendar";
2
+ import type {
3
+ CalendarColumns,
4
+ CalendarRangeValue,
5
+ CalendarValue,
6
+ } from "../../calendar";
3
7
 
4
8
  const DATE_FORMAT = "YYYY-MM-DD";
5
9
 
@@ -9,6 +13,12 @@ const DATE_FORMAT = "YYYY-MM-DD";
9
13
  */
10
14
  export const createEmptyValue = (): CalendarValue => null;
11
15
 
16
+ /**
17
+ * Calendar range에 빈 값을 선언한다.
18
+ * @returns {CalendarRangeValue} 시작/종료가 모두 비어 있는 range tuple
19
+ */
20
+ export const createEmptyRangeValue = (): CalendarRangeValue => [null, null];
21
+
12
22
  /**
13
23
  * Date 객체를 YYYY-MM-DD 문자열로 포맷한다.
14
24
  * @param {Date | string | null} date 포맷 대상
@@ -67,6 +77,36 @@ export const formatTriggerValue = (
67
77
  return parsed.format(format);
68
78
  };
69
79
 
80
+ /**
81
+ * range trigger에 표시할 문자열을 계산한다.
82
+ * @param {CalendarRangeValue} value 현재 range tuple
83
+ * @param {string} [format="YYYY-MM-DD"] dayjs 표시 포맷
84
+ * @returns {string} range trigger 표시 문자열
85
+ * @example
86
+ * formatRangeTriggerValue(["2026-02-23", "2026-02-27"]);
87
+ */
88
+ export const formatRangeTriggerValue = (
89
+ value: CalendarRangeValue,
90
+ format = DATE_FORMAT,
91
+ ) => {
92
+ const start = formatTriggerValue(value[0], format);
93
+ const end = formatTriggerValue(value[1], format);
94
+
95
+ if (!start && !end) {
96
+ return "";
97
+ }
98
+
99
+ if (!end) {
100
+ return `${start} ~`;
101
+ }
102
+
103
+ if (!start) {
104
+ return `~ ${end}`;
105
+ }
106
+
107
+ return `${start} ~ ${end}`;
108
+ };
109
+
70
110
  /**
71
111
  * columns 값을 Mantine numberOfColumns와 맞춘다.
72
112
  * @param {CalendarColumns} columns 열 개수