@uniai-fe/uds-primitives 0.7.4 → 0.7.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useId } from "react";
5
5
  import { PopOver } from "../../pop-over";
6
6
  import type { CalendarRootProps } from "../types";
7
+ import {
8
+ getCalendarTriggerClassName,
9
+ isOwnCalendarTriggerTarget,
10
+ } from "../utils";
7
11
  import CalendarContainer from "./layout/Container";
8
12
  import CalendarCore from "./Core";
9
13
 
@@ -62,6 +66,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
62
66
  ) => {
63
67
  // disabled/readOnly 상태에서는 PopOver 토글을 막는다.
64
68
  const isInteractive = !disabled && !readOnly;
69
+ const triggerId = useId();
70
+ const triggerClassName = getCalendarTriggerClassName(triggerId);
65
71
 
66
72
  const calendarNode = (
67
73
  <CalendarContainer
@@ -99,7 +105,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
99
105
  <PopOver.Trigger
100
106
  asChild
101
107
  disabled={!isInteractive}
102
- className={clsx("calendar-trigger", className)}
108
+ className={clsx("calendar-trigger", triggerClassName, className)}
109
+ data-calendar-trigger-id={triggerId}
103
110
  >
104
111
  {/* children은 asChild 계약상 단일 element여야 하며, 해당 요소가 props를 DOM으로 전달해야 한다. */}
105
112
  {children}
@@ -113,9 +120,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
113
120
  withPortal={withPortal}
114
121
  portalContainer={portalContainer}
115
122
  onInteractOutside={event => {
116
- // 변경 설명: trigger 클릭은 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
117
- const nextTarget = event.target as HTMLElement | null;
118
- if (nextTarget?.closest(".calendar-trigger")) {
123
+ // 자기 trigger 클릭만 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
124
+ if (isOwnCalendarTriggerTarget(event.target, triggerId)) {
119
125
  event.preventDefault();
120
126
  }
121
127
  }}
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useId } from "react";
5
5
  import { PopOver } from "../../../pop-over";
6
6
  import type { CalendarRangeRootProps } from "../../types";
7
+ import {
8
+ getCalendarTriggerClassName,
9
+ isOwnCalendarTriggerTarget,
10
+ } from "../../utils";
7
11
  import CalendarContainer from "../layout/Container";
8
12
  import CalendarRangeCore from "./Core";
9
13
 
@@ -60,6 +64,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
60
64
  ) => {
61
65
  // disabled/readOnly 상태에서는 range PopOver 토글을 막는다.
62
66
  const isInteractive = !disabled && !readOnly;
67
+ const triggerId = useId();
68
+ const triggerClassName = getCalendarTriggerClassName(triggerId);
63
69
 
64
70
  const calendarNode = (
65
71
  <CalendarContainer
@@ -97,7 +103,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
97
103
  <PopOver.Trigger
98
104
  asChild
99
105
  disabled={!isInteractive}
100
- className={clsx("calendar-trigger", className)}
106
+ className={clsx("calendar-trigger", triggerClassName, className)}
107
+ data-calendar-trigger-id={triggerId}
101
108
  >
102
109
  {/* children은 asChild 계약상 단일 element여야 하며, 해당 요소가 props를 DOM으로 전달해야 한다. */}
103
110
  {children}
@@ -111,9 +118,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
111
118
  withPortal={withPortal}
112
119
  portalContainer={portalContainer}
113
120
  onInteractOutside={event => {
114
- // 변경 설명: trigger 클릭은 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
115
- const nextTarget = event.target as HTMLElement | null;
116
- if (nextTarget?.closest(".calendar-trigger")) {
121
+ // 자기 trigger 클릭만 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
122
+ if (isOwnCalendarTriggerTarget(event.target, triggerId)) {
117
123
  event.preventDefault();
118
124
  }
119
125
  }}
@@ -1 +1,2 @@
1
+ export * from "./trigger";
1
2
  export * from "./value-mapper";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Calendar Utils; Trigger DOM 식별 className 생성 함수.
3
+ * React rendering key가 아니라 Radix PopOver trigger DOM을 식별하기 위한 className을 만든다.
4
+ * @param {string} triggerId React useId로 생성한 Calendar Root 인스턴스 식별자
5
+ * @returns {string} CSS selector에 사용할 수 있는 trigger className
6
+ */
7
+ export const getCalendarTriggerClassName = (triggerId: string): string =>
8
+ `calendar-trigger-${triggerId.replaceAll(":", "")}`;
9
+
10
+ /**
11
+ * Calendar Utils; 자기 trigger outside interaction 판별 함수.
12
+ * PopOver content 밖 interaction 중 현재 Calendar Root의 trigger 클릭만 dismiss 예외로 처리한다.
13
+ * @param {EventTarget | null} target PopOver outside interaction target
14
+ * @param {string} triggerId React useId로 생성한 Calendar Root 인스턴스 식별자
15
+ * @returns {boolean} target이 현재 Calendar Root의 trigger 내부이면 true
16
+ */
17
+ export const isOwnCalendarTriggerTarget = (
18
+ target: EventTarget | null,
19
+ triggerId: string,
20
+ ): boolean => {
21
+ if (!(target instanceof HTMLElement)) {
22
+ return false;
23
+ }
24
+
25
+ return Boolean(target.closest(`[data-calendar-trigger-id="${triggerId}"]`));
26
+ };
@@ -45,6 +45,7 @@ const INPUT_DATE_TABLE_FORMAT = "YY-MM-DD";
45
45
  * @param {InputCalendarTexts} [props.texts] 기본 Date 문구
46
46
  * @param {unknown} [props.timePicker] TimePicker 확장용 예약 슬롯(현재 미구현)
47
47
  * @param {boolean} [props.calendarOpened] calendar 열림 제어 상태
48
+ * @param {(open: boolean) => void} [props.onCalendarOpen] calendar 열림 변경 이벤트
48
49
  * @param {(event: MouseEvent<Element>) => void} [props.onClick] trigger 클릭 핸들러
49
50
  * @param {string} [props.id] trigger id
50
51
  * @param {ReactNode} [props.trigger] 커스텀 trigger 슬롯
@@ -71,6 +72,7 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
71
72
  footer,
72
73
  texts,
73
74
  calendarOpened,
75
+ onCalendarOpen,
74
76
  onClick: triggerOnClick,
75
77
  id,
76
78
  trigger,
@@ -82,6 +84,20 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
82
84
  const [internalCalendarOpen, setInternalCalendarOpen] = useState(false);
83
85
  const resolvedCalendarOpen = calendarOpened ?? internalCalendarOpen;
84
86
 
87
+ /**
88
+ * Input Date Template; Calendar open 상태 변경 전달 함수.
89
+ * 비제어 사용을 위해 내부 상태를 갱신하고, 제어형 사용을 위해 외부 owner에게 next open 값을 전달한다.
90
+ * @param {boolean} nextOpen 다음 calendar open 상태
91
+ * @returns {void}
92
+ */
93
+ const handleCalendarOpenChange = useCallback(
94
+ (nextOpen: boolean) => {
95
+ setInternalCalendarOpen(nextOpen);
96
+ onCalendarOpen?.(nextOpen);
97
+ },
98
+ [onCalendarOpen],
99
+ );
100
+
85
101
  // useUncontrolled로 제어형/비제어 값을 모두 허용한다.
86
102
  const [calendarValue, setCalendarValue] = useUncontrolled<CalendarValue>({
87
103
  value,
@@ -93,7 +109,12 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
93
109
  },
94
110
  });
95
111
 
96
- // react-hook-form register onChange를 수동 호출해 값 직렬화를 맞춘다.
112
+ /**
113
+ * Input Date Template; RHF hidden input 변경 이벤트 동기화 함수.
114
+ * trigger 표시값과 form 저장값을 분리하기 위해 CalendarValue를 직렬화한 synthetic change를 register에 전달한다.
115
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
116
+ * @returns {void}
117
+ */
97
118
  const emitRegisterChange = useCallback(
98
119
  (nextValue: CalendarValue) => {
99
120
  if (!register?.onChange) {
@@ -115,6 +136,12 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
115
136
  [register],
116
137
  );
117
138
 
139
+ /**
140
+ * Input Date Template; calendar value 갱신 함수.
141
+ * 내부/제어형 value pipeline을 갱신한 뒤 RHF register 저장값을 같은 CalendarValue 기준으로 동기화한다.
142
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
143
+ * @returns {void}
144
+ */
118
145
  const updateValue = useCallback(
119
146
  (nextValue: CalendarValue) => {
120
147
  setCalendarValue(nextValue);
@@ -133,10 +160,22 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
133
160
  );
134
161
  const serializedCalendarValue = serializeCalendarValue(calendarValue);
135
162
 
163
+ /**
164
+ * Input Date Template; trigger click 위임 함수.
165
+ * Calendar Root의 PopOver toggle은 asChild trigger props가 담당하고, 이 함수는 소비자가 넘긴 trigger onClick만 보존한다.
166
+ * @param {ReactMouseEvent<Element>} event trigger click event
167
+ * @returns {void}
168
+ */
136
169
  const handleTriggerClick = (event: ReactMouseEvent<Element>) => {
137
170
  triggerOnClick?.(event);
138
171
  };
139
172
 
173
+ /**
174
+ * Input Date Template; Calendar Core change adapter 함수.
175
+ * Calendar.Root의 onChange 값을 Template value/RHF 동기화 pipeline으로 연결한다.
176
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
177
+ * @returns {void}
178
+ */
140
179
  const handleCalendarChange = (nextValue: CalendarValue) => {
141
180
  updateValue(nextValue);
142
181
  };
@@ -147,8 +186,8 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
147
186
  disabled={disabled}
148
187
  onClear={() => updateValue(createEmptyValue())}
149
188
  onToday={() => updateValue(getTodayValue())}
150
- // 변경: Apply 버튼 클릭 시 calendar를 닫는다.
151
- onApply={() => setInternalCalendarOpen(false)}
189
+ // Apply 버튼 클릭 시 calendar를 닫는다.
190
+ onApply={() => handleCalendarOpenChange(false)}
152
191
  texts={texts}
153
192
  />
154
193
  );
@@ -194,7 +233,7 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
194
233
  header={header}
195
234
  footer={footerContent}
196
235
  open={resolvedCalendarOpen}
197
- onOpenChange={setInternalCalendarOpen}
236
+ onOpenChange={handleCalendarOpenChange}
198
237
  >
199
238
  {triggerNode}
200
239
  </Calendar.Root>
@@ -3,6 +3,7 @@
3
3
  import type { ChangeEvent, MouseEvent as ReactMouseEvent } from "react";
4
4
  import { forwardRef, useCallback, useMemo, useState } from "react";
5
5
  import { useUncontrolled } from "@mantine/hooks";
6
+ import type { UseFormRegisterReturn } from "react-hook-form";
6
7
  import { Calendar } from "../../../../calendar";
7
8
  import type {
8
9
  InputCalendarRangeProps,
@@ -48,6 +49,7 @@ const INPUT_DATE_RANGE_TABLE_FORMAT = "YY-MM-DD";
48
49
  * @param {ReactNode} [props.footer] 패널 footer 콘텐츠
49
50
  * @param {InputCalendarTexts} [props.texts] 기본 Date 문구
50
51
  * @param {boolean} [props.calendarOpened] calendar 열림 제어 상태
52
+ * @param {(open: boolean) => void} [props.onCalendarOpen] calendar 열림 변경 이벤트
51
53
  * @param {string} [props.id] trigger id
52
54
  * @param {ReactNode} [props.trigger] 커스텀 trigger 슬롯
53
55
  * @param {(props: InputCalendarTriggerRenderProps) => ReactNode} [props.renderTrigger] 커스텀 trigger 렌더 함수
@@ -81,6 +83,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
81
83
  footer,
82
84
  texts,
83
85
  calendarOpened,
86
+ onCalendarOpen,
84
87
  onClick: triggerOnClick,
85
88
  id,
86
89
  trigger,
@@ -92,6 +95,20 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
92
95
  const [internalCalendarOpen, setInternalCalendarOpen] = useState(false);
93
96
  const resolvedCalendarOpen = calendarOpened ?? internalCalendarOpen;
94
97
 
98
+ /**
99
+ * Input Date Range Template; Calendar open 상태 변경 전달 함수.
100
+ * 비제어 range calendar 상태를 갱신하고, 제어형 owner가 열림 상태를 조율할 수 있게 next open 값을 전달한다.
101
+ * @param {boolean} nextOpen 다음 range calendar open 상태
102
+ * @returns {void}
103
+ */
104
+ const handleCalendarOpenChange = useCallback(
105
+ (nextOpen: boolean) => {
106
+ setInternalCalendarOpen(nextOpen);
107
+ onCalendarOpen?.(nextOpen);
108
+ },
109
+ [onCalendarOpen],
110
+ );
111
+
95
112
  // Calendar.Range tuple 계약을 그대로 유지하며 제어형/비제어 값을 모두 허용한다.
96
113
  const [calendarValue, setCalendarValue] =
97
114
  useUncontrolled<CalendarRangeValue>({
@@ -104,11 +121,23 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
104
121
  },
105
122
  });
106
123
 
107
- // react-hook-form register onChange를 start/end 각각 호출해 hidden input 저장값을 맞춘다.
124
+ /**
125
+ * Input Date Range Template; RHF hidden input 변경 이벤트 동기화 함수.
126
+ * range tuple의 start/end 값을 각각 직렬화해 startRegister/endRegister에 synthetic change로 전달한다.
127
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
128
+ * @returns {void}
129
+ */
108
130
  const emitRegisterChange = useCallback(
109
131
  (nextValue: CalendarRangeValue) => {
132
+ /**
133
+ * Input Date Range Template; 단일 range endpoint register 동기화 함수.
134
+ * start 또는 end register 한쪽에 직렬화 값을 전달한다.
135
+ * @param {UseFormRegisterReturn | undefined} register RHF register 결과
136
+ * @param {CalendarValue} nextCalendarValue 다음 start/end 선택 값
137
+ * @returns {void}
138
+ */
110
139
  const emit = (
111
- register: typeof startRegister | typeof endRegister | undefined,
140
+ register: UseFormRegisterReturn | undefined,
112
141
  nextCalendarValue: CalendarValue,
113
142
  ) => {
114
143
  if (!register?.onChange) {
@@ -136,6 +165,12 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
136
165
  [endRegister, startRegister],
137
166
  );
138
167
 
168
+ /**
169
+ * Input Date Range Template; range value 갱신 함수.
170
+ * Calendar.Range tuple 값을 갱신하고 start/end hidden input 저장값을 같은 tuple 기준으로 동기화한다.
171
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
172
+ * @returns {void}
173
+ */
139
174
  const updateValue = useCallback(
140
175
  (nextValue: CalendarRangeValue) => {
141
176
  setCalendarValue(nextValue);
@@ -155,10 +190,22 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
155
190
  const serializedStartValue = serializeCalendarValue(calendarValue[0]);
156
191
  const serializedEndValue = serializeCalendarValue(calendarValue[1]);
157
192
 
193
+ /**
194
+ * Input Date Range Template; trigger click 위임 함수.
195
+ * Calendar.Range Root의 PopOver toggle은 asChild trigger props가 담당하고, 이 함수는 소비자가 넘긴 trigger onClick만 보존한다.
196
+ * @param {ReactMouseEvent<Element>} event trigger click event
197
+ * @returns {void}
198
+ */
158
199
  const handleTriggerClick = (event: ReactMouseEvent<Element>) => {
159
200
  triggerOnClick?.(event);
160
201
  };
161
202
 
203
+ /**
204
+ * Input Date Range Template; Calendar Range Core change adapter 함수.
205
+ * Calendar.Range.Root의 onChange 값을 Template tuple/RHF 동기화 pipeline으로 연결한다.
206
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
207
+ * @returns {void}
208
+ */
162
209
  const handleCalendarChange = (nextValue: CalendarRangeValue) => {
163
210
  updateValue(nextValue);
164
211
  };
@@ -190,7 +237,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
190
237
  className="input-date-range-apply-button"
191
238
  disabled={disabled}
192
239
  label={texts?.apply}
193
- onClick={() => setInternalCalendarOpen(false)}
240
+ onClick={() => handleCalendarOpenChange(false)}
194
241
  />
195
242
  </InputDateFooterUtilContainer>
196
243
  </InputDateFooterTemplateContainer>
@@ -234,7 +281,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
234
281
  header={header}
235
282
  footer={footerContent}
236
283
  open={resolvedCalendarOpen}
237
- onOpenChange={setInternalCalendarOpen}
284
+ onOpenChange={handleCalendarOpenChange}
238
285
  >
239
286
  {triggerNode}
240
287
  </Calendar.Range.Root>
@@ -151,6 +151,7 @@ export interface InputCalendarTriggerRenderProps {
151
151
  * @property {InputCalendarTexts} [texts] 기본 Date 문구
152
152
  * @property {unknown} [timePicker] TimePicker 확장용 예약 슬롯(현재 미구현)
153
153
  * @property {boolean} [calendarOpened] calendar 열림 제어 여부
154
+ * @property {(open: boolean) => void} [onCalendarOpen] calendar 열림 변경 이벤트
154
155
  * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
155
156
  * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
156
157
  */
@@ -213,6 +214,10 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
213
214
  * - 지정되면 제어형(open state controlled)으로 동작한다.
214
215
  */
215
216
  calendarOpened?: boolean;
217
+ /**
218
+ * calendar 열림 변경 이벤트
219
+ */
220
+ onCalendarOpen?: (open: boolean) => void;
216
221
  /**
217
222
  * 커스텀 trigger 슬롯
218
223
  */
@@ -243,6 +248,7 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
243
248
  * @property {ReactNode} [footer] 커스텀 footer 콘텐츠
244
249
  * @property {InputCalendarTexts} [texts] 기본 Date 문구
245
250
  * @property {boolean} [calendarOpened] calendar 열림 제어 여부
251
+ * @property {(open: boolean) => void} [onCalendarOpen] calendar 열림 변경 이벤트
246
252
  * @property {string} [id] trigger id
247
253
  * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
248
254
  * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
@@ -323,6 +329,10 @@ export interface InputCalendarRangeProps {
323
329
  * - 지정되면 제어형(open state controlled)으로 동작한다.
324
330
  */
325
331
  calendarOpened?: boolean;
332
+ /**
333
+ * calendar 열림 변경 이벤트
334
+ */
335
+ onCalendarOpen?: (open: boolean) => void;
326
336
  /**
327
337
  * trigger id
328
338
  */