@uniai-fe/uds-primitives 0.11.0 → 0.12.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.
Files changed (30) hide show
  1. package/README.md +38 -453
  2. package/dist/styles.css +370 -327
  3. package/package.json +2 -2
  4. package/src/components/input/index.tsx +1 -1
  5. package/src/components/input/markup/time/Template.tsx +412 -0
  6. package/src/components/input/markup/time/Trigger.tsx +233 -0
  7. package/src/components/input/markup/time/index.tsx +9 -2
  8. package/src/components/input/styles/time.scss +160 -317
  9. package/src/components/input/styles/variables.scss +9 -47
  10. package/src/components/input/types/time.ts +263 -120
  11. package/src/components/time-picker/img/minus.svg +3 -0
  12. package/src/components/time-picker/img/plus.svg +3 -0
  13. package/src/components/time-picker/index.scss +1 -0
  14. package/src/components/time-picker/index.tsx +11 -0
  15. package/src/components/time-picker/markup/Footer.tsx +53 -0
  16. package/src/components/time-picker/markup/HourSection.tsx +72 -0
  17. package/src/components/time-picker/markup/MinuteSection.tsx +68 -0
  18. package/src/components/time-picker/markup/Summary.tsx +19 -0
  19. package/src/components/time-picker/markup/Template.tsx +86 -0
  20. package/src/components/time-picker/markup/index.tsx +10 -0
  21. package/src/components/time-picker/styles/index.scss +2 -0
  22. package/src/components/time-picker/styles/template.scss +174 -0
  23. package/src/components/time-picker/styles/variables.scss +64 -0
  24. package/src/components/time-picker/types/index.ts +1 -0
  25. package/src/components/time-picker/types/time-picker.ts +193 -0
  26. package/src/components/time-picker/utils/index.ts +1 -0
  27. package/src/components/time-picker/utils/time.ts +110 -0
  28. package/src/index.scss +1 -0
  29. package/src/index.tsx +1 -0
  30. package/src/components/input/markup/time/Picker.tsx +0 -378
@@ -0,0 +1,110 @@
1
+ import type {
2
+ TimePickerFormat,
3
+ TimePickerParts,
4
+ TimePickerPeriod,
5
+ TimePickerUnit,
6
+ } from "../types";
7
+
8
+ const padTimePart = (value: number) => String(value).padStart(2, "0");
9
+
10
+ const wrapTimePart = (value: number, limit: number) =>
11
+ ((value % limit) + limit) % limit;
12
+
13
+ /**
14
+ * TimePicker Utility; 24시간 문자열을 시간 값으로 파싱한다.
15
+ * @param {string} value HH:mm 또는 HH:mm:ss 값
16
+ * @returns {TimePickerParts | null} 파싱 결과
17
+ */
18
+ export const parseTimeValue = (value: string): TimePickerParts | null => {
19
+ const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
20
+ if (!match) {
21
+ return null;
22
+ }
23
+
24
+ const hours = Number(match[1]);
25
+ const minutes = Number(match[2]);
26
+ const seconds = Number(match[3] ?? 0);
27
+ if (hours > 23 || minutes > 59 || seconds > 59) {
28
+ return null;
29
+ }
30
+
31
+ return { hours, minutes, seconds };
32
+ };
33
+
34
+ /**
35
+ * TimePicker Utility; 시간 값을 24시간 문자열로 직렬화한다.
36
+ * @param {TimePickerParts} parts 시간 값
37
+ * @param {boolean} withSeconds 초 포함 여부
38
+ * @returns {string} HH:mm 또는 HH:mm:ss 값
39
+ */
40
+ export const serializeTimeValue = (
41
+ parts: TimePickerParts,
42
+ withSeconds: boolean,
43
+ ) =>
44
+ [parts.hours, parts.minutes, ...(withSeconds ? [parts.seconds] : [])]
45
+ .map(padTimePart)
46
+ .join(":");
47
+
48
+ /**
49
+ * TimePicker Utility; 지정 단위만 순환 증감한다.
50
+ * @param {string} value 시간 값
51
+ * @param {TimePickerUnit} unit 증감 단위
52
+ * @param {number} amount 증감량
53
+ * @param {boolean} withSeconds 초 포함 여부
54
+ * @returns {string} 증감한 시간 값
55
+ */
56
+ export const stepTimeValue = (
57
+ value: string,
58
+ unit: TimePickerUnit,
59
+ amount: number,
60
+ withSeconds: boolean,
61
+ ) => {
62
+ const parts = parseTimeValue(value) ?? {
63
+ hours: 0,
64
+ minutes: 0,
65
+ seconds: 0,
66
+ };
67
+ const limit = unit === "hours" ? 24 : 60;
68
+ return serializeTimeValue(
69
+ { ...parts, [unit]: wrapTimePart(parts[unit] + amount, limit) },
70
+ withSeconds,
71
+ );
72
+ };
73
+
74
+ /**
75
+ * TimePicker Utility; 저장 시를 표시 시로 변환한다.
76
+ * @param {number} hours 24시간 시 값
77
+ * @param {TimePickerFormat} format 표시 형식
78
+ * @returns {number} 표시 시 값
79
+ */
80
+ export const toDisplayHour = (hours: number, format: TimePickerFormat) =>
81
+ format === "12h" ? hours % 12 || 12 : hours;
82
+
83
+ /**
84
+ * TimePicker Utility; 24시간 시 값의 오전·오후를 반환한다.
85
+ * @param {number} hours 24시간 시 값
86
+ * @returns {TimePickerPeriod} 오전·오후
87
+ */
88
+ export const getTimePeriod = (hours: number): TimePickerPeriod =>
89
+ hours >= 12 ? "pm" : "am";
90
+
91
+ /**
92
+ * TimePicker Utility; 오전·오후를 24시간 저장값에 반영한다.
93
+ * @param {string} value 시간 값
94
+ * @param {TimePickerPeriod} period 오전·오후
95
+ * @param {boolean} withSeconds 초 포함 여부
96
+ * @returns {string} 변경한 시간 값
97
+ */
98
+ export const setTimePeriod = (
99
+ value: string,
100
+ period: TimePickerPeriod,
101
+ withSeconds: boolean,
102
+ ) => {
103
+ const parts = parseTimeValue(value) ?? {
104
+ hours: 0,
105
+ minutes: 0,
106
+ seconds: 0,
107
+ };
108
+ const hours = (parts.hours % 12) + (period === "pm" ? 12 : 0);
109
+ return serializeTimeValue({ ...parts, hours }, withSeconds);
110
+ };
package/src/index.scss CHANGED
@@ -22,5 +22,6 @@
22
22
  @use "./components/switch";
23
23
  @use "./components/tab";
24
24
  @use "./components/table";
25
+ @use "./components/time-picker";
25
26
  @use "./components/toast";
26
27
  @use "./components/tooltip";
package/src/index.tsx CHANGED
@@ -28,6 +28,7 @@ export * from "./components/spinner";
28
28
  export * from "./components/switch";
29
29
  export * from "./components/tab";
30
30
  export * from "./components/table";
31
+ export * from "./components/time-picker";
31
32
  export * from "./components/toast";
32
33
  export * from "./components/tooltip";
33
34
  export type * from "./types";
@@ -1,378 +0,0 @@
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;