@uniai-fe/uds-primitives 0.6.15 → 0.7.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.
@@ -0,0 +1,363 @@
1
+ "use client";
2
+
3
+ import { TimePicker as MantineTimePicker } from "@mantine/dates";
4
+ import { useUncontrolled } from "@mantine/hooks";
5
+ import clsx from "clsx";
6
+ import type { CSSProperties, ChangeEvent, FocusEvent, MouseEvent } from "react";
7
+ import { forwardRef, useCallback, useRef, useState } from "react";
8
+ import type { TimePickerStylesNames } from "@mantine/dates";
9
+ import {
10
+ getFormFieldWidthAttr,
11
+ getFormFieldWidthValue,
12
+ } from "../../../form/utils/form-field";
13
+ import type { InputTimePickerProps } from "../../types";
14
+ import ClockIcon from "../../img/clock.svg";
15
+ import ResetIcon from "../../img/reset.svg";
16
+
17
+ const INPUT_TIME_CLASS_NAMES = {
18
+ input: "input-time-input",
19
+ wrapper: "input-time-wrapper",
20
+ section: "input-time-section",
21
+ fieldsRoot: "input-time-fields-root",
22
+ fieldsGroup: "input-time-fields-group",
23
+ field: "input-time-field",
24
+ dropdown: "input-time-dropdown",
25
+ controlsListGroup: "input-time-controls-list-group",
26
+ controlsList: "input-time-controls-list",
27
+ control: "input-time-option",
28
+ presetControl: "input-time-preset-option",
29
+ presetsGroup: "input-time-presets-group",
30
+ presetsGroupLabel: "input-time-presets-group-label",
31
+ scrollarea: "input-time-scrollarea",
32
+ } satisfies Partial<Record<TimePickerStylesNames, string>>;
33
+
34
+ /**
35
+ * Input Time Picker; Mantine TimePicker 기반 UDS adapter.
36
+ * `Input.Time.Picker` public namespace에서 사용하며 Calendar/date 하위에 속하지 않는다.
37
+ * @component
38
+ * @param {InputTimePickerProps} props
39
+ * @param {string} [props.value] 제어형 시간 값
40
+ * @param {string} [props.defaultValue] 비제어 초기 시간 값
41
+ * @param {(value:string)=>void} [props.onChange] 값 변경 핸들러
42
+ * @param {(value:string)=>void} [props.onValueChange] 값 변경 별칭
43
+ * @param {UseFormRegisterReturn} [props.register] react-hook-form register 결과
44
+ * @param {"primary" | "secondary" | "tertiary" | "table"} [props.priority="primary"] input priority
45
+ * @param {"small" | "medium" | "large"} [props.size="medium"] input size
46
+ * @param {"default" | "active" | "focused" | "success" | "error" | "disabled" | "loading"} [props.state="default"] input state
47
+ * @param {"12h" | "24h"} [props.format="24h"] 표시/입력 시간 포맷
48
+ * @param {boolean} [props.withSeconds=false] seconds 입력 노출 여부
49
+ * @param {boolean} [props.withDropdown=false] dropdown 노출 여부
50
+ * @param {boolean} [props.clearable=true] clear 버튼 노출 여부
51
+ * @param {"left" | "right"} [props.iconPosition] clock icon 위치
52
+ */
53
+ const InputTimePicker = forwardRef<HTMLDivElement, InputTimePickerProps>(
54
+ (
55
+ {
56
+ value,
57
+ defaultValue,
58
+ onChange,
59
+ onValueChange,
60
+ name,
61
+ form,
62
+ register,
63
+ priority = "primary",
64
+ size = "medium",
65
+ state: stateProp = "default",
66
+ block = false,
67
+ width,
68
+ className,
69
+ disabled,
70
+ readOnly,
71
+ required,
72
+ id,
73
+ format = "24h",
74
+ withSeconds = false,
75
+ withDropdown = false,
76
+ clearable = true,
77
+ min,
78
+ max,
79
+ hoursStep,
80
+ minutesStep,
81
+ secondsStep,
82
+ icon,
83
+ iconPosition,
84
+ iconLabel = "시간 선택",
85
+ clearButtonLabel = "시간 지우기",
86
+ popoverProps,
87
+ hiddenInputProps,
88
+ hoursInputLabel,
89
+ minutesInputLabel,
90
+ secondsInputLabel,
91
+ amPmInputLabel,
92
+ amPmLabels,
93
+ hoursPlaceholder,
94
+ minutesPlaceholder,
95
+ secondsPlaceholder,
96
+ pasteSplit,
97
+ presets,
98
+ maxDropdownContentHeight,
99
+ scrollAreaProps,
100
+ reverseTimeControlsList,
101
+ onFocus,
102
+ onBlur,
103
+ },
104
+ ref,
105
+ ) => {
106
+ const hoursInputRef = useRef<HTMLInputElement | null>(null);
107
+ const [isFocused, setIsFocused] = useState(false);
108
+ const [timeValue, setTimeValue] = useUncontrolled<string>({
109
+ value,
110
+ defaultValue,
111
+ finalValue: "",
112
+ onChange: nextValue => {
113
+ onChange?.(nextValue);
114
+ onValueChange?.(nextValue);
115
+ },
116
+ });
117
+
118
+ const emitRegisterChange = useCallback(
119
+ (nextValue: string) => {
120
+ if (!register?.onChange) {
121
+ return;
122
+ }
123
+ const syntheticEvent = {
124
+ target: {
125
+ name: register.name,
126
+ value: nextValue,
127
+ },
128
+ currentTarget: {
129
+ name: register.name,
130
+ value: nextValue,
131
+ },
132
+ } as unknown as ChangeEvent<HTMLInputElement>;
133
+ register.onChange(syntheticEvent);
134
+ },
135
+ [register],
136
+ );
137
+
138
+ const emitRegisterBlur = useCallback(
139
+ (nextValue: string) => {
140
+ if (!register?.onBlur) {
141
+ return;
142
+ }
143
+ const syntheticEvent = {
144
+ target: {
145
+ name: register.name,
146
+ value: nextValue,
147
+ },
148
+ currentTarget: {
149
+ name: register.name,
150
+ value: nextValue,
151
+ },
152
+ } as unknown as FocusEvent<HTMLInputElement>;
153
+ register.onBlur(syntheticEvent);
154
+ },
155
+ [register],
156
+ );
157
+
158
+ const currentState = disabled ? "disabled" : stateProp;
159
+ const isDisabled =
160
+ currentState === "disabled" || currentState === "loading";
161
+ const visualState =
162
+ priority === "tertiary"
163
+ ? isDisabled
164
+ ? "disabled"
165
+ : "default"
166
+ : !isDisabled && isFocused
167
+ ? "active"
168
+ : currentState;
169
+ const resolvedIconPosition =
170
+ iconPosition ?? (priority === "table" ? "left" : "right");
171
+ const resolvedBlock =
172
+ block || (priority === "table" && width === undefined);
173
+ const widthAttr =
174
+ width !== undefined
175
+ ? getFormFieldWidthAttr(width)
176
+ : resolvedBlock
177
+ ? "full"
178
+ : undefined;
179
+ const widthValue =
180
+ width !== undefined ? getFormFieldWidthValue(width) : undefined;
181
+ const containerStyle: CSSProperties | undefined =
182
+ widthValue !== undefined
183
+ ? ({ ["--input-time-width" as const]: widthValue } as CSSProperties)
184
+ : undefined;
185
+ const resolvedIcon = icon === undefined ? <ClockIcon /> : icon;
186
+ const showClearButton = Boolean(
187
+ clearable && timeValue && !isDisabled && !readOnly,
188
+ );
189
+ const reserveClearSpace = Boolean(clearable && !isDisabled && !readOnly);
190
+ const rightSectionWidth =
191
+ resolvedIconPosition === "right"
192
+ ? reserveClearSpace
193
+ ? "var(--input-time-section-width-with-clear)"
194
+ : "var(--input-time-section-width)"
195
+ : showClearButton
196
+ ? "var(--input-time-section-width)"
197
+ : undefined;
198
+
199
+ const handleChange = (nextValue: string) => {
200
+ setTimeValue(nextValue);
201
+ emitRegisterChange(nextValue);
202
+ };
203
+
204
+ const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
205
+ setIsFocused(true);
206
+ onFocus?.(event);
207
+ };
208
+
209
+ const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
210
+ setIsFocused(false);
211
+ emitRegisterBlur(timeValue);
212
+ onBlur?.(event);
213
+ };
214
+
215
+ const handleIconClick = (event: MouseEvent<HTMLButtonElement>) => {
216
+ event.preventDefault();
217
+ if (isDisabled || readOnly) {
218
+ return;
219
+ }
220
+ hoursInputRef.current?.focus();
221
+ };
222
+
223
+ const handleClearClick = (event: MouseEvent<HTMLButtonElement>) => {
224
+ event.preventDefault();
225
+ if (isDisabled || readOnly) {
226
+ return;
227
+ }
228
+ handleChange("");
229
+ hoursInputRef.current?.focus();
230
+ };
231
+
232
+ const iconButton = resolvedIcon ? (
233
+ <button
234
+ type="button"
235
+ className="input-time-section-button input-time-icon-button"
236
+ tabIndex={-1}
237
+ aria-label={iconLabel}
238
+ aria-disabled={isDisabled || readOnly ? true : undefined}
239
+ onMouseDown={event => event.preventDefault()}
240
+ onClick={handleIconClick}
241
+ >
242
+ <span className="input-time-icon-graphic" aria-hidden="true">
243
+ {resolvedIcon}
244
+ </span>
245
+ </button>
246
+ ) : null;
247
+
248
+ const clearButton = showClearButton ? (
249
+ <button
250
+ type="button"
251
+ className="input-time-section-button input-time-clear-button"
252
+ tabIndex={-1}
253
+ aria-label={clearButtonLabel}
254
+ onMouseDown={event => event.preventDefault()}
255
+ onClick={handleClearClick}
256
+ >
257
+ <ResetIcon aria-hidden="true" />
258
+ </button>
259
+ ) : null;
260
+
261
+ const rightSection =
262
+ resolvedIconPosition === "right" ? (
263
+ <span className="input-time-section-group">
264
+ {clearButton}
265
+ {iconButton}
266
+ </span>
267
+ ) : (
268
+ clearButton
269
+ );
270
+
271
+ return (
272
+ <div
273
+ id={id}
274
+ className={clsx("input-time", className)}
275
+ data-priority={priority}
276
+ data-size={size}
277
+ data-state={visualState}
278
+ data-readonly={readOnly ? "true" : undefined}
279
+ data-block={resolvedBlock ? "true" : undefined}
280
+ data-icon-position={resolvedIconPosition}
281
+ data-has-value={timeValue ? "true" : "false"}
282
+ data-clearable={reserveClearSpace ? "true" : undefined}
283
+ data-has-clear={showClearButton ? "true" : undefined}
284
+ data-width={widthAttr}
285
+ style={containerStyle}
286
+ >
287
+ <MantineTimePicker
288
+ ref={ref}
289
+ className="input-time-picker"
290
+ classNames={INPUT_TIME_CLASS_NAMES}
291
+ value={timeValue}
292
+ onChange={handleChange}
293
+ name={register ? undefined : name}
294
+ form={register ? undefined : form}
295
+ required={required}
296
+ disabled={isDisabled}
297
+ readOnly={readOnly}
298
+ format={format}
299
+ withSeconds={withSeconds}
300
+ withDropdown={withDropdown}
301
+ clearable={false}
302
+ min={min}
303
+ max={max}
304
+ hoursStep={hoursStep}
305
+ minutesStep={minutesStep}
306
+ secondsStep={secondsStep}
307
+ hoursInputLabel={hoursInputLabel}
308
+ minutesInputLabel={minutesInputLabel}
309
+ secondsInputLabel={secondsInputLabel}
310
+ amPmInputLabel={amPmInputLabel}
311
+ amPmLabels={amPmLabels}
312
+ hoursPlaceholder={hoursPlaceholder}
313
+ minutesPlaceholder={minutesPlaceholder}
314
+ secondsPlaceholder={secondsPlaceholder}
315
+ pasteSplit={pasteSplit}
316
+ presets={presets}
317
+ maxDropdownContentHeight={maxDropdownContentHeight}
318
+ scrollAreaProps={scrollAreaProps}
319
+ reverseTimeControlsList={reverseTimeControlsList}
320
+ popoverProps={{
321
+ width: "target",
322
+ withinPortal: false,
323
+ ...popoverProps,
324
+ }}
325
+ hiddenInputProps={register ? undefined : hiddenInputProps}
326
+ size="sm"
327
+ variant="unstyled"
328
+ withErrorStyles={false}
329
+ aria-invalid={visualState === "error" ? true : undefined}
330
+ leftSection={resolvedIconPosition === "left" ? iconButton : undefined}
331
+ rightSection={rightSection}
332
+ leftSectionWidth={
333
+ resolvedIconPosition === "left"
334
+ ? "var(--input-time-left-section-width)"
335
+ : undefined
336
+ }
337
+ rightSectionWidth={rightSectionWidth}
338
+ leftSectionPointerEvents={
339
+ resolvedIconPosition === "left" ? "auto" : undefined
340
+ }
341
+ rightSectionPointerEvents={rightSection ? "auto" : undefined}
342
+ hoursRef={hoursInputRef}
343
+ onFocus={handleFocus}
344
+ onBlur={handleBlur}
345
+ />
346
+ {register ? (
347
+ <input
348
+ {...hiddenInputProps}
349
+ {...register}
350
+ type="hidden"
351
+ name={register.name}
352
+ form={form ?? hiddenInputProps?.form}
353
+ value={timeValue}
354
+ />
355
+ ) : null}
356
+ </div>
357
+ );
358
+ },
359
+ );
360
+
361
+ InputTimePicker.displayName = "InputTimePicker";
362
+
363
+ export default InputTimePicker;
@@ -0,0 +1,6 @@
1
+ import "../../styles/time.scss";
2
+ import InputTimePicker from "./Picker";
3
+
4
+ export const InputTime = {
5
+ Picker: InputTimePicker,
6
+ };