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