@uniai-fe/uds-primitives 0.11.0 → 0.12.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.
Files changed (29) hide show
  1. package/README.md +38 -453
  2. package/dist/styles.css +370 -327
  3. package/package.json +3 -3
  4. package/src/components/input/markup/time/Template.tsx +423 -0
  5. package/src/components/input/markup/time/Trigger.tsx +289 -0
  6. package/src/components/input/markup/time/index.tsx +2 -2
  7. package/src/components/input/styles/time.scss +160 -317
  8. package/src/components/input/styles/variables.scss +9 -47
  9. package/src/components/input/types/time.ts +61 -120
  10. package/src/components/time-picker/img/minus.svg +3 -0
  11. package/src/components/time-picker/img/plus.svg +3 -0
  12. package/src/components/time-picker/index.scss +1 -0
  13. package/src/components/time-picker/index.tsx +11 -0
  14. package/src/components/time-picker/markup/Footer.tsx +71 -0
  15. package/src/components/time-picker/markup/HourSection.tsx +99 -0
  16. package/src/components/time-picker/markup/MinuteSection.tsx +86 -0
  17. package/src/components/time-picker/markup/Summary.tsx +24 -0
  18. package/src/components/time-picker/markup/Template.tsx +86 -0
  19. package/src/components/time-picker/markup/index.tsx +10 -0
  20. package/src/components/time-picker/styles/index.scss +2 -0
  21. package/src/components/time-picker/styles/template.scss +174 -0
  22. package/src/components/time-picker/styles/variables.scss +64 -0
  23. package/src/components/time-picker/types/index.ts +1 -0
  24. package/src/components/time-picker/types/time-picker.ts +94 -0
  25. package/src/components/time-picker/utils/index.ts +1 -0
  26. package/src/components/time-picker/utils/time.ts +110 -0
  27. package/src/index.scss +1 -0
  28. package/src/index.tsx +1 -0
  29. package/src/components/input/markup/time/Picker.tsx +0 -378
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -87,10 +87,10 @@
87
87
  "react-hook-form": "^7.82.0",
88
88
  "sass": "^1.101.3",
89
89
  "typescript": "6.0.3",
90
- "@uniai-fe/next-devkit": "0.4.0",
91
90
  "@uniai-fe/eslint-config": "0.4.2",
92
- "@uniai-fe/tsconfig": "0.2.0",
91
+ "@uniai-fe/next-devkit": "0.4.0",
93
92
  "@uniai-fe/util-functions": "0.4.3",
93
+ "@uniai-fe/tsconfig": "0.2.0",
94
94
  "@uniai-fe/uds-foundation": "0.5.0"
95
95
  },
96
96
  "scripts": {
@@ -0,0 +1,423 @@
1
+ "use client";
2
+
3
+ import { useUncontrolled } from "@mantine/hooks";
4
+ import type { CSSProperties, FocusEvent, KeyboardEvent } from "react";
5
+ import { forwardRef, useCallback, useEffect, useState } from "react";
6
+ import { PopOver } from "../../../pop-over";
7
+ import {
8
+ getFormFieldWidthAttr,
9
+ getFormFieldWidthValue,
10
+ } from "../../../form/utils/form-field";
11
+ import { TimePicker } from "../../../time-picker";
12
+ import type {
13
+ TimePickerParts,
14
+ TimePickerPeriod,
15
+ TimePickerUnit,
16
+ } from "../../../time-picker/types";
17
+ import {
18
+ getTimePeriod,
19
+ parseTimeValue,
20
+ serializeTimeValue,
21
+ setTimePeriod,
22
+ stepTimeValue,
23
+ toDisplayHour,
24
+ } from "../../../time-picker/utils";
25
+ import ClockIcon from "../../img/clock.svg";
26
+ import type { InputTimeTemplateProps } from "../../types";
27
+ import InputTimeTrigger from "./Trigger";
28
+
29
+ interface InputTimeDraftFields {
30
+ hours: string;
31
+ minutes: string;
32
+ seconds: string;
33
+ }
34
+
35
+ interface InputTimeDraft {
36
+ fields: InputTimeDraftFields;
37
+ period: TimePickerPeriod;
38
+ }
39
+
40
+ const getInputTimeDraft = (
41
+ value: string,
42
+ format: "12h" | "24h",
43
+ ): InputTimeDraft => {
44
+ const parts = parseTimeValue(value);
45
+ if (!parts) {
46
+ return {
47
+ fields: { hours: "", minutes: "", seconds: "" },
48
+ period: "am",
49
+ };
50
+ }
51
+
52
+ return {
53
+ fields: {
54
+ hours: String(toDisplayHour(parts.hours, format)).padStart(2, "0"),
55
+ minutes: String(parts.minutes).padStart(2, "0"),
56
+ seconds: String(parts.seconds).padStart(2, "0"),
57
+ },
58
+ period: getTimePeriod(parts.hours),
59
+ };
60
+ };
61
+
62
+ /**
63
+ * Input Time Template; native trigger와 PopOver·TimePicker 선택 panel 조합.
64
+ * 저장값은 24시간 `HH:mm` 또는 `HH:mm:ss` 문자열로 유지한다.
65
+ * @component
66
+ * @param {InputTimeTemplateProps} props
67
+ * @param {"primary" | "secondary" | "tertiary" | "table"} [props.priority="primary"] input priority
68
+ * @param {"small" | "medium" | "large"} [props.size="medium"] input size
69
+ * @param {"12h" | "24h"} [props.format="24h"] 표시 형식
70
+ * @param {boolean} [props.withSeconds=false] 초 field 노출 여부
71
+ * @returns {ReactNode} 시간 입력과 선택 panel
72
+ */
73
+ const InputTimeTemplate = forwardRef<HTMLDivElement, InputTimeTemplateProps>(
74
+ (
75
+ {
76
+ value,
77
+ defaultValue,
78
+ onChange,
79
+ onValueChange,
80
+ name,
81
+ form,
82
+ register,
83
+ priority = "primary",
84
+ size = "medium",
85
+ state: stateProp = "default",
86
+ block = false,
87
+ width,
88
+ className,
89
+ disabled,
90
+ readOnly = false,
91
+ required,
92
+ id,
93
+ format = "24h",
94
+ withSeconds = false,
95
+ clearable = true,
96
+ hoursStep = 1,
97
+ minutesStep = 5,
98
+ secondsStep = 1,
99
+ icon,
100
+ iconPosition,
101
+ iconLabel = "시간 선택",
102
+ hiddenInputProps,
103
+ hoursInputLabel = "시",
104
+ minutesInputLabel = "분",
105
+ secondsInputLabel = "초",
106
+ amPmInputLabel = "오전 또는 오후",
107
+ hoursPlaceholder = "HH",
108
+ minutesPlaceholder = "MM",
109
+ secondsPlaceholder = "SS",
110
+ onFocus,
111
+ onBlur,
112
+ },
113
+ ref,
114
+ ) => {
115
+ const [isOpen, setIsOpen] = useState(false);
116
+ const [activeUnit, setActiveUnit] = useState<TimePickerUnit | null>(null);
117
+ const [timeValue, setTimeValue] = useUncontrolled<string>({
118
+ value,
119
+ defaultValue,
120
+ finalValue: "",
121
+ onChange: nextValue => {
122
+ onChange?.(nextValue);
123
+ onValueChange?.(nextValue);
124
+ },
125
+ });
126
+ const [draft, setDraft] = useState<InputTimeDraft>(() =>
127
+ getInputTimeDraft(value ?? defaultValue ?? "", format),
128
+ );
129
+
130
+ useEffect(() => {
131
+ setDraft(getInputTimeDraft(timeValue, format));
132
+ }, [format, timeValue]);
133
+
134
+ const isDisabled =
135
+ disabled || stateProp === "disabled" || stateProp === "loading";
136
+ const visualState =
137
+ priority === "tertiary"
138
+ ? isDisabled
139
+ ? "disabled"
140
+ : "default"
141
+ : !isDisabled && activeUnit
142
+ ? "active"
143
+ : stateProp;
144
+ const resolvedIconPosition =
145
+ iconPosition ?? (priority === "table" ? "left" : "right");
146
+ const resolvedBlock =
147
+ block || (priority === "table" && width === undefined);
148
+ const widthAttr =
149
+ width !== undefined
150
+ ? getFormFieldWidthAttr(width)
151
+ : resolvedBlock
152
+ ? "full"
153
+ : undefined;
154
+ const widthValue =
155
+ width !== undefined ? getFormFieldWidthValue(width) : undefined;
156
+ const triggerStyle: CSSProperties | undefined = widthValue
157
+ ? { width: widthValue }
158
+ : undefined;
159
+
160
+ const emitRegisterChange = useCallback(
161
+ (nextValue: string) => {
162
+ register?.onChange({
163
+ target: { name: register.name, value: nextValue },
164
+ type: "change",
165
+ });
166
+ },
167
+ [register],
168
+ );
169
+
170
+ const updateValue = useCallback(
171
+ (nextValue: string) => {
172
+ setDraft(getInputTimeDraft(nextValue, format));
173
+ setTimeValue(nextValue);
174
+ emitRegisterChange(nextValue);
175
+ },
176
+ [emitRegisterChange, format, setTimeValue],
177
+ );
178
+
179
+ const commitDraft = useCallback(
180
+ (nextDraft: InputTimeDraft) => {
181
+ const { hours, minutes, seconds } = nextDraft.fields;
182
+ if (
183
+ hours.length !== 2 ||
184
+ minutes.length !== 2 ||
185
+ (withSeconds && seconds.length !== 2)
186
+ ) {
187
+ return;
188
+ }
189
+
190
+ const displayHours = Number(hours);
191
+ const nextMinutes = Number(minutes);
192
+ const nextSeconds = withSeconds ? Number(seconds) : 0;
193
+ const validHours =
194
+ format === "12h"
195
+ ? displayHours >= 1 && displayHours <= 12
196
+ : displayHours >= 0 && displayHours <= 23;
197
+ if (!validHours || nextMinutes > 59 || nextSeconds > 59) {
198
+ return;
199
+ }
200
+
201
+ const storedHours =
202
+ format === "12h"
203
+ ? (displayHours % 12) + (nextDraft.period === "pm" ? 12 : 0)
204
+ : displayHours;
205
+ updateValue(
206
+ serializeTimeValue(
207
+ {
208
+ hours: storedHours,
209
+ minutes: nextMinutes,
210
+ seconds: nextSeconds,
211
+ },
212
+ withSeconds,
213
+ ),
214
+ );
215
+ },
216
+ [format, updateValue, withSeconds],
217
+ );
218
+
219
+ const handleFieldChange = (unit: TimePickerUnit, rawValue: string) => {
220
+ const nextValue = rawValue.replace(/\D/g, "").slice(0, 2);
221
+ const nextDraft = {
222
+ ...draft,
223
+ fields: { ...draft.fields, [unit]: nextValue },
224
+ };
225
+ setDraft(nextDraft);
226
+ commitDraft(nextDraft);
227
+ };
228
+
229
+ const handleFieldFocus = (
230
+ unit: TimePickerUnit,
231
+ event: FocusEvent<HTMLInputElement>,
232
+ ) => {
233
+ setActiveUnit(unit);
234
+ event.currentTarget.select();
235
+ onFocus?.(event);
236
+ };
237
+
238
+ const handleFieldBlur = (unit: TimePickerUnit) => {
239
+ const currentValue = draft.fields[unit];
240
+ if (!currentValue) {
241
+ return;
242
+ }
243
+
244
+ const max = unit === "hours" ? (format === "12h" ? 12 : 23) : 59;
245
+ const min = unit === "hours" && format === "12h" ? 1 : 0;
246
+ const normalized = String(
247
+ Math.min(max, Math.max(min, Number(currentValue))),
248
+ ).padStart(2, "0");
249
+ const nextDraft = {
250
+ ...draft,
251
+ fields: { ...draft.fields, [unit]: normalized },
252
+ };
253
+ setDraft(nextDraft);
254
+ commitDraft(nextDraft);
255
+ };
256
+
257
+ const handleFieldKeyDown = (
258
+ unit: TimePickerUnit,
259
+ event: KeyboardEvent<HTMLInputElement>,
260
+ ) => {
261
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") {
262
+ return;
263
+ }
264
+ event.preventDefault();
265
+ const amount = event.key === "ArrowUp" ? 1 : -1;
266
+ const step =
267
+ unit === "hours"
268
+ ? hoursStep
269
+ : unit === "minutes"
270
+ ? minutesStep
271
+ : secondsStep;
272
+ updateValue(stepTimeValue(timeValue, unit, amount * step, withSeconds));
273
+ };
274
+
275
+ const handleStep = (amount: 1 | -1) => {
276
+ if (!activeUnit) {
277
+ return;
278
+ }
279
+ const step =
280
+ activeUnit === "hours"
281
+ ? hoursStep
282
+ : activeUnit === "minutes"
283
+ ? minutesStep
284
+ : secondsStep;
285
+ updateValue(
286
+ stepTimeValue(timeValue, activeUnit, amount * step, withSeconds),
287
+ );
288
+ };
289
+
290
+ const handlePeriodChange = (period: TimePickerPeriod) => {
291
+ const nextValue = setTimePeriod(timeValue, period, withSeconds);
292
+ updateValue(nextValue);
293
+ };
294
+
295
+ const handleHourSelect = (hours: number) => {
296
+ const parts = parseTimeValue(timeValue) ?? {
297
+ hours: 0,
298
+ minutes: 0,
299
+ seconds: 0,
300
+ };
301
+ const storedHours =
302
+ format === "12h"
303
+ ? (hours % 12) + (draft.period === "pm" ? 12 : 0)
304
+ : hours;
305
+ updateValue(
306
+ serializeTimeValue({ ...parts, hours: storedHours }, withSeconds),
307
+ );
308
+ };
309
+
310
+ const handleMinuteSelect = (minutes: number) => {
311
+ const parts: TimePickerParts = parseTimeValue(timeValue) ?? {
312
+ hours: 0,
313
+ minutes: 0,
314
+ seconds: 0,
315
+ };
316
+ updateValue(serializeTimeValue({ ...parts, minutes }, withSeconds));
317
+ };
318
+
319
+ const handleRootBlur = (event: FocusEvent<HTMLDivElement>) => {
320
+ if (
321
+ event.relatedTarget instanceof Node &&
322
+ event.currentTarget.contains(event.relatedTarget)
323
+ ) {
324
+ return;
325
+ }
326
+ setActiveUnit(null);
327
+ register?.onBlur({
328
+ target: { name: register.name, value: timeValue },
329
+ type: "blur",
330
+ });
331
+ onBlur?.(event);
332
+ };
333
+
334
+ const handleOpenChange = (nextOpen: boolean) => {
335
+ setIsOpen(nextOpen && !isDisabled && !readOnly);
336
+ };
337
+
338
+ return (
339
+ <>
340
+ <PopOver.Root open={isOpen} onOpenChange={handleOpenChange}>
341
+ <PopOver.Trigger asChild>
342
+ <InputTimeTrigger
343
+ ref={ref}
344
+ id={id}
345
+ className={className}
346
+ style={triggerStyle}
347
+ fields={draft.fields}
348
+ period={draft.period}
349
+ activeUnit={activeUnit}
350
+ priority={priority}
351
+ size={size}
352
+ state={visualState}
353
+ format={format}
354
+ withSeconds={withSeconds}
355
+ disabled={Boolean(isDisabled)}
356
+ readOnly={readOnly}
357
+ icon={
358
+ icon === undefined ? <ClockIcon aria-hidden="true" /> : icon
359
+ }
360
+ iconPosition={resolvedIconPosition}
361
+ iconLabel={iconLabel}
362
+ hoursInputLabel={hoursInputLabel}
363
+ minutesInputLabel={minutesInputLabel}
364
+ secondsInputLabel={secondsInputLabel}
365
+ amPmInputLabel={amPmInputLabel}
366
+ hoursPlaceholder={hoursPlaceholder}
367
+ minutesPlaceholder={minutesPlaceholder}
368
+ secondsPlaceholder={secondsPlaceholder}
369
+ data-block={resolvedBlock ? "true" : undefined}
370
+ data-width={widthAttr}
371
+ onFieldChange={handleFieldChange}
372
+ onFieldFocus={handleFieldFocus}
373
+ onFieldBlur={handleFieldBlur}
374
+ onFieldKeyDown={handleFieldKeyDown}
375
+ onPeriodChange={handlePeriodChange}
376
+ onStep={handleStep}
377
+ onRootBlur={handleRootBlur}
378
+ />
379
+ </PopOver.Trigger>
380
+ <PopOver.Content
381
+ className="input-time-pop-over"
382
+ width={336}
383
+ align="start"
384
+ sideOffset={8}
385
+ onOpenAutoFocus={event => event.preventDefault()}
386
+ >
387
+ <TimePicker.Template
388
+ value={timeValue}
389
+ format={format}
390
+ clearable={clearable}
391
+ disabled={Boolean(isDisabled || readOnly)}
392
+ onHourSelect={handleHourSelect}
393
+ onMinuteSelect={handleMinuteSelect}
394
+ onPeriodChange={handlePeriodChange}
395
+ onMinuteStep={amount =>
396
+ updateValue(
397
+ stepTimeValue(timeValue, "minutes", amount, withSeconds),
398
+ )
399
+ }
400
+ onClear={() => updateValue("")}
401
+ onApply={() => setIsOpen(false)}
402
+ />
403
+ </PopOver.Content>
404
+ </PopOver.Root>
405
+ {register || name ? (
406
+ <input
407
+ {...hiddenInputProps}
408
+ {...register}
409
+ type="hidden"
410
+ name={register?.name ?? name}
411
+ form={form ?? hiddenInputProps?.form}
412
+ required={required}
413
+ value={timeValue}
414
+ />
415
+ ) : null}
416
+ </>
417
+ );
418
+ },
419
+ );
420
+
421
+ InputTimeTemplate.displayName = "InputTimeTemplate";
422
+
423
+ export default InputTimeTemplate;