@uniai-fe/uds-primitives 0.9.3 → 0.10.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.
- package/package.json +5 -5
- package/src/components/calendar/markup/Core.tsx +22 -12
- package/src/components/carousel/hooks/useContext.ts +3 -12
- package/src/components/carousel/markup/Track.tsx +7 -9
- package/src/components/drawer/markup/Drawer.tsx +59 -22
- package/src/components/dropdown/markup/Template.tsx +20 -20
- package/src/components/input/hooks/useDigitField.ts +7 -10
- package/src/components/input/hooks/useInputFileContext.ts +22 -5
- package/src/components/input/markup/date/Trigger.tsx +2 -2
- package/src/components/radio/markup/RadioCardGroup.tsx +2 -2
- package/src/components/segmented-control/markup/List.tsx +1 -1
- package/src/components/segmented-control/types/index.ts +2 -2
- package/src/components/select/hooks/interaction.ts +3 -10
- package/src/components/select/markup/Default.tsx +46 -33
- package/src/components/select/markup/multiple/Multiple.tsx +25 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "UNIAI Design System; Primitives Components Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -87,11 +87,11 @@
|
|
|
87
87
|
"react-hook-form": "^7.80.0",
|
|
88
88
|
"sass": "^1.101.0",
|
|
89
89
|
"typescript": "6.0.3",
|
|
90
|
-
"@uniai-fe/eslint-config": "0.
|
|
91
|
-
"@uniai-fe/
|
|
92
|
-
"@uniai-fe/
|
|
90
|
+
"@uniai-fe/eslint-config": "0.4.0",
|
|
91
|
+
"@uniai-fe/util-functions": "0.4.1",
|
|
92
|
+
"@uniai-fe/next-devkit": "0.4.0",
|
|
93
93
|
"@uniai-fe/tsconfig": "0.2.0",
|
|
94
|
-
"@uniai-fe/
|
|
94
|
+
"@uniai-fe/uds-foundation": "0.5.0"
|
|
95
95
|
},
|
|
96
96
|
"scripts": {
|
|
97
97
|
"check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
DatesProvider,
|
|
6
6
|
type DateStringValue,
|
|
7
7
|
} from "@mantine/dates";
|
|
8
|
-
import {
|
|
8
|
+
import { useState } from "react";
|
|
9
9
|
import { dayjs } from "../../../init/dayjs";
|
|
10
10
|
import { CalendarIcon } from "./Icon";
|
|
11
11
|
import type { CalendarDatePickerProps, CalendarGridProps } from "../types";
|
|
@@ -43,16 +43,23 @@ export default function CalendarCore({
|
|
|
43
43
|
void valueFormat;
|
|
44
44
|
|
|
45
45
|
const isDisplayedDateControlled = date !== undefined;
|
|
46
|
-
const [
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const [displayedDateState, setDisplayedDateState] = useState(() => ({
|
|
47
|
+
defaultDate,
|
|
48
|
+
displayedDate: date ?? mapValueToPicker(value) ?? defaultDate ?? new Date(),
|
|
49
|
+
value,
|
|
50
|
+
}));
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
if (
|
|
53
|
+
!isDisplayedDateControlled &&
|
|
54
|
+
(displayedDateState.value !== value ||
|
|
55
|
+
displayedDateState.defaultDate !== defaultDate)
|
|
56
|
+
) {
|
|
57
|
+
setDisplayedDateState({
|
|
58
|
+
defaultDate,
|
|
59
|
+
displayedDate: mapValueToPicker(value) ?? defaultDate ?? new Date(),
|
|
60
|
+
value,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
56
63
|
|
|
57
64
|
// 기본 DatePicker 옵션/스타일 책임을 Calendar.Core에 고정한다.
|
|
58
65
|
const resolvedDatePickerProps = {
|
|
@@ -101,7 +108,10 @@ export default function CalendarCore({
|
|
|
101
108
|
|
|
102
109
|
const handleDateChange = (nextDate: DateStringValue) => {
|
|
103
110
|
if (!isDisplayedDateControlled) {
|
|
104
|
-
|
|
111
|
+
setDisplayedDateState(previousState => ({
|
|
112
|
+
...previousState,
|
|
113
|
+
displayedDate: nextDate,
|
|
114
|
+
}));
|
|
105
115
|
}
|
|
106
116
|
onDateChange?.(nextDate);
|
|
107
117
|
};
|
|
@@ -124,7 +134,7 @@ export default function CalendarCore({
|
|
|
124
134
|
value={mapValueToPicker(value) as never}
|
|
125
135
|
onChange={handleChange as never}
|
|
126
136
|
{...resolvedDatePickerProps}
|
|
127
|
-
date={date ?? displayedDate}
|
|
137
|
+
date={date ?? displayedDateState.displayedDate}
|
|
128
138
|
onDateChange={handleDateChange}
|
|
129
139
|
/>
|
|
130
140
|
</DatesProvider>
|
|
@@ -183,6 +183,9 @@ export const useCarouselProviderController = ({
|
|
|
183
183
|
setCurrentIndex(previousIndex =>
|
|
184
184
|
Math.min(previousIndex, Math.max(normalizedCount - visibleCount, 0)),
|
|
185
185
|
);
|
|
186
|
+
setFocusIndexState(previousFocusIndex =>
|
|
187
|
+
Math.min(previousFocusIndex, Math.max(normalizedCount - 1, 0)),
|
|
188
|
+
);
|
|
186
189
|
},
|
|
187
190
|
[visibleCount],
|
|
188
191
|
);
|
|
@@ -234,11 +237,6 @@ export const useCarouselProviderController = ({
|
|
|
234
237
|
);
|
|
235
238
|
}, [fixedVisibleCount, itemCount]);
|
|
236
239
|
|
|
237
|
-
useEffect(() => {
|
|
238
|
-
// item 수/폭 조건 변화 시 visibleCount를 재계산한다.
|
|
239
|
-
updateVisibleCount();
|
|
240
|
-
}, [updateVisibleCount, itemCount]);
|
|
241
|
-
|
|
242
240
|
useEffect(() => {
|
|
243
241
|
/**
|
|
244
242
|
* ResizeObserver effect
|
|
@@ -502,13 +500,6 @@ export const useCarouselProviderController = ({
|
|
|
502
500
|
}
|
|
503
501
|
}, [currentIndex, onIndexChange]);
|
|
504
502
|
|
|
505
|
-
useEffect(() => {
|
|
506
|
-
// 아이템 수 감소 시 focusIndex 범위를 보정한다.
|
|
507
|
-
setFocusIndexState(previousFocusIndex =>
|
|
508
|
-
Math.min(previousFocusIndex, Math.max(itemCount - 1, 0)),
|
|
509
|
-
);
|
|
510
|
-
}, [itemCount]);
|
|
511
|
-
|
|
512
503
|
useEffect(() => {
|
|
513
504
|
// 상태 변화마다 edge를 재평가해 버튼 disabled 오차를 줄인다.
|
|
514
505
|
syncScrollEdgeState();
|
|
@@ -32,17 +32,18 @@ const CarouselTrack = ({
|
|
|
32
32
|
itemClassName,
|
|
33
33
|
...restProps
|
|
34
34
|
}: CarouselTrackProps) => {
|
|
35
|
-
const
|
|
35
|
+
const { focusIndex, registerItemCount, trackRef, viewportRef } =
|
|
36
|
+
useCarousel();
|
|
36
37
|
const childArray = Children.toArray(children);
|
|
37
38
|
const itemCount = childArray.length;
|
|
38
39
|
|
|
39
40
|
useEffect(() => {
|
|
40
41
|
// track에 렌더된 panel 개수를 Provider에 등록한다.
|
|
41
|
-
|
|
42
|
-
}, [
|
|
42
|
+
registerItemCount(itemCount);
|
|
43
|
+
}, [itemCount, registerItemCount]);
|
|
43
44
|
|
|
44
45
|
const enhancedChildren = childArray.map((child, index) => {
|
|
45
|
-
const isFocused = index ===
|
|
46
|
+
const isFocused = index === focusIndex;
|
|
46
47
|
const itemProps = {
|
|
47
48
|
className: clsx("carousel-item", itemClassName),
|
|
48
49
|
"data-carousel-index": index,
|
|
@@ -68,13 +69,10 @@ const CarouselTrack = ({
|
|
|
68
69
|
return (
|
|
69
70
|
<div
|
|
70
71
|
className={clsx("carousel-viewport", className)}
|
|
71
|
-
ref={
|
|
72
|
+
ref={viewportRef}
|
|
72
73
|
{...restProps}
|
|
73
74
|
>
|
|
74
|
-
<ul
|
|
75
|
-
className={clsx("carousel-track", trackClassName)}
|
|
76
|
-
ref={carousel.trackRef}
|
|
77
|
-
>
|
|
75
|
+
<ul className={clsx("carousel-track", trackClassName)} ref={trackRef}>
|
|
78
76
|
{enhancedChildren}
|
|
79
77
|
</ul>
|
|
80
78
|
</div>
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
|
2
2
|
import clsx from "clsx";
|
|
3
3
|
import {
|
|
4
|
+
Component,
|
|
4
5
|
forwardRef,
|
|
5
6
|
useCallback,
|
|
6
7
|
useEffect,
|
|
7
8
|
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
11
|
+
useSyncExternalStore,
|
|
10
12
|
} from "react";
|
|
11
13
|
import { createPortal } from "react-dom";
|
|
12
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
ComponentPropsWithoutRef,
|
|
16
|
+
CSSProperties,
|
|
17
|
+
ForwardedRef,
|
|
18
|
+
ReactNode,
|
|
19
|
+
} from "react";
|
|
13
20
|
import CloseIcon from "../img/close.svg";
|
|
14
21
|
import type {
|
|
15
22
|
DrawerContextValue,
|
|
@@ -34,6 +41,23 @@ import {
|
|
|
34
41
|
} from "../utils";
|
|
35
42
|
|
|
36
43
|
const ANIMATION_DURATION = 320;
|
|
44
|
+
const subscribeToClient = () => () => undefined;
|
|
45
|
+
const getClientSnapshot = () => true;
|
|
46
|
+
const getServerSnapshot = () => false;
|
|
47
|
+
|
|
48
|
+
interface DrawerButtonLikeProps {
|
|
49
|
+
asChild?: boolean;
|
|
50
|
+
buttonProps: ComponentPropsWithoutRef<"button">;
|
|
51
|
+
buttonRef: ForwardedRef<HTMLButtonElement>;
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class DrawerButtonLike extends Component<DrawerButtonLikeProps> {
|
|
56
|
+
override render() {
|
|
57
|
+
const { asChild, buttonProps, buttonRef, children } = this.props;
|
|
58
|
+
return renderButtonLike(asChild, children, buttonProps, buttonRef);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
63
|
* DrawerRoot — open 상태/phase를 관리하는 컨텍스트 루트.
|
|
@@ -55,7 +79,17 @@ const DrawerRoot = ({
|
|
|
55
79
|
defaultOpen ?? false,
|
|
56
80
|
);
|
|
57
81
|
const open = isControlled ? (openProp as boolean) : uncontrolledOpen;
|
|
58
|
-
const [
|
|
82
|
+
const [phaseState, setPhaseState] = useState(() => ({
|
|
83
|
+
open,
|
|
84
|
+
phase: (open ? "entered" : "exited") as DrawerPhase,
|
|
85
|
+
}));
|
|
86
|
+
if (phaseState.open !== open) {
|
|
87
|
+
setPhaseState({
|
|
88
|
+
open,
|
|
89
|
+
phase: open ? "entering" : "exiting",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const phase = phaseState.phase;
|
|
59
93
|
const closeTimerRef = useRef<number | null>(null);
|
|
60
94
|
const enterRafRef = useRef<number | null>(null);
|
|
61
95
|
|
|
@@ -75,9 +109,8 @@ const DrawerRoot = ({
|
|
|
75
109
|
window.clearTimeout(closeTimerRef.current);
|
|
76
110
|
closeTimerRef.current = null;
|
|
77
111
|
}
|
|
78
|
-
setPhase("entering");
|
|
79
112
|
enterRafRef.current = window.requestAnimationFrame(() => {
|
|
80
|
-
|
|
113
|
+
setPhaseState({ open: true, phase: "entered" });
|
|
81
114
|
enterRafRef.current = null;
|
|
82
115
|
});
|
|
83
116
|
return () => {
|
|
@@ -91,9 +124,8 @@ const DrawerRoot = ({
|
|
|
91
124
|
window.cancelAnimationFrame(enterRafRef.current);
|
|
92
125
|
enterRafRef.current = null;
|
|
93
126
|
}
|
|
94
|
-
setPhase("exiting");
|
|
95
127
|
closeTimerRef.current = window.setTimeout(() => {
|
|
96
|
-
|
|
128
|
+
setPhaseState({ open: false, phase: "exited" });
|
|
97
129
|
closeTimerRef.current = null;
|
|
98
130
|
}, ANIMATION_DURATION);
|
|
99
131
|
return () => {
|
|
@@ -142,11 +174,14 @@ const DrawerTrigger = forwardRef<HTMLButtonElement, DrawerTriggerProps>(
|
|
|
142
174
|
({ asChild, children, onClick, ...props }, forwardedRef) => {
|
|
143
175
|
const { setOpen } = useDrawerContext();
|
|
144
176
|
const handleClick = composeEventHandlers(onClick, () => setOpen(true));
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
177
|
+
return (
|
|
178
|
+
<DrawerButtonLike
|
|
179
|
+
asChild={asChild}
|
|
180
|
+
buttonProps={{ ...props, onClick: handleClick }}
|
|
181
|
+
buttonRef={forwardedRef}
|
|
182
|
+
>
|
|
183
|
+
{children}
|
|
184
|
+
</DrawerButtonLike>
|
|
150
185
|
);
|
|
151
186
|
},
|
|
152
187
|
);
|
|
@@ -160,12 +195,11 @@ DrawerTrigger.displayName = "DrawerTrigger";
|
|
|
160
195
|
*/
|
|
161
196
|
const DrawerPortal = ({ children }: DrawerPortalProps) => {
|
|
162
197
|
useDrawerContext();
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}, []);
|
|
198
|
+
const mounted = useSyncExternalStore(
|
|
199
|
+
subscribeToClient,
|
|
200
|
+
getClientSnapshot,
|
|
201
|
+
getServerSnapshot,
|
|
202
|
+
);
|
|
169
203
|
|
|
170
204
|
if (!mounted) {
|
|
171
205
|
return null;
|
|
@@ -453,11 +487,14 @@ const DrawerClose = forwardRef<HTMLButtonElement, DrawerCloseProps>(
|
|
|
453
487
|
({ asChild, children, onClick, ...props }, forwardedRef) => {
|
|
454
488
|
const { setOpen } = useDrawerContext();
|
|
455
489
|
const handleClick = composeEventHandlers(onClick, () => setOpen(false));
|
|
456
|
-
return
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
490
|
+
return (
|
|
491
|
+
<DrawerButtonLike
|
|
492
|
+
asChild={asChild}
|
|
493
|
+
buttonProps={{ ...props, onClick: handleClick }}
|
|
494
|
+
buttonRef={forwardedRef}
|
|
495
|
+
>
|
|
496
|
+
{children}
|
|
497
|
+
</DrawerButtonLike>
|
|
461
498
|
);
|
|
462
499
|
},
|
|
463
500
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
DropdownTemplateItem,
|
|
@@ -82,30 +82,27 @@ const DropdownTemplate = ({
|
|
|
82
82
|
* - Template은 items[].selected를 초기값으로 받아 내부 상태를 관리한다.
|
|
83
83
|
* - 초기값은 selectedValuesFromItems를 그대로 사용한다.
|
|
84
84
|
*/
|
|
85
|
-
const [
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
const [selectionState, setSelectionState] = useState(() => ({
|
|
86
|
+
selectedValues: selectedValuesFromItems,
|
|
87
|
+
selectedValuesFromItems,
|
|
88
|
+
}));
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* 4) options(items) 변경 동기화
|
|
91
92
|
* - source of truth를 items[].selected로 고정해 외부 선택 상태를 즉시 반영한다.
|
|
92
93
|
* - single/multiple 정책은 normalizeSelectedValuesByMode로 통일 적용한다.
|
|
93
94
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return previousSelectedValues;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return nextSelectedValues;
|
|
95
|
+
if (
|
|
96
|
+
!isSameSelectedValueList(
|
|
97
|
+
selectionState.selectedValuesFromItems,
|
|
98
|
+
selectedValuesFromItems,
|
|
99
|
+
)
|
|
100
|
+
) {
|
|
101
|
+
setSelectionState({
|
|
102
|
+
selectedValues: selectedValuesFromItems,
|
|
103
|
+
selectedValuesFromItems,
|
|
107
104
|
});
|
|
108
|
-
}
|
|
105
|
+
}
|
|
109
106
|
|
|
110
107
|
/**
|
|
111
108
|
* 5) 최종 선택 value 계산
|
|
@@ -113,7 +110,7 @@ const DropdownTemplate = ({
|
|
|
113
110
|
* - 렌더/이벤트/selected 스타일 계산은 이 값만 참조한다.
|
|
114
111
|
*/
|
|
115
112
|
const resolvedSelectedValues = normalizeSelectedValuesByMode(
|
|
116
|
-
|
|
113
|
+
selectionState.selectedValues,
|
|
117
114
|
isMultiple,
|
|
118
115
|
);
|
|
119
116
|
const selectedValueKeySet = useMemo(
|
|
@@ -173,7 +170,10 @@ const DropdownTemplate = ({
|
|
|
173
170
|
* 7-3) 내부 state 업데이트
|
|
174
171
|
* - Template 내부 선택 상태를 즉시 반영한다.
|
|
175
172
|
*/
|
|
176
|
-
|
|
173
|
+
setSelectionState(previousState => ({
|
|
174
|
+
...previousState,
|
|
175
|
+
selectedValues: nextSelectedValues,
|
|
176
|
+
}));
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
179
|
* 7-4) 선택 결과 이벤트(onChange)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { ChangeEvent, useCallback,
|
|
3
|
+
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
|
4
4
|
import type { UseDigitFieldOptions, UseDigitFieldResult } from "../types";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -31,18 +31,15 @@ export const useDigitField = ({
|
|
|
31
31
|
clampDigits(defaultValue ?? ""),
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
setInnerDigits(prev => clampDigits(prev));
|
|
40
|
-
}, [clampDigits, isControlled]);
|
|
34
|
+
const clampedInnerDigits = clampDigits(innerDigits);
|
|
35
|
+
if (!isControlled && innerDigits !== clampedInnerDigits) {
|
|
36
|
+
setInnerDigits(clampedInnerDigits);
|
|
37
|
+
}
|
|
41
38
|
|
|
42
39
|
const resolvedDigits = useMemo(() => {
|
|
43
|
-
const target = isControlled ? (value ?? "") :
|
|
40
|
+
const target = isControlled ? (value ?? "") : clampedInnerDigits;
|
|
44
41
|
return clampDigits(target);
|
|
45
|
-
}, [clampDigits,
|
|
42
|
+
}, [clampDigits, clampedInnerDigits, isControlled, value]);
|
|
46
43
|
|
|
47
44
|
const handleDigitsChange = useCallback(
|
|
48
45
|
(event: ChangeEvent<HTMLInputElement>) => {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
4
11
|
import type {
|
|
5
12
|
InputFileEntry,
|
|
6
13
|
InputFileEvent,
|
|
@@ -47,11 +54,12 @@ export const useInputFileContext = ({
|
|
|
47
54
|
initialFiles,
|
|
48
55
|
mergeMode = "append",
|
|
49
56
|
}: UseInputFileContextOptions = {}): UseInputFileContextResult => {
|
|
57
|
+
const initialEntryId = useId();
|
|
50
58
|
/**
|
|
51
59
|
* Input Hook; File Upload 엔트리 고유 id 시퀀스 ref
|
|
52
60
|
*/
|
|
53
61
|
// 파일이 추가될 때마다 증가하는 시퀀스를 유지한다.
|
|
54
|
-
const entryIdRef = useRef(0);
|
|
62
|
+
const entryIdRef = useRef(initialFiles?.length ?? 0);
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
65
|
* Input Hook; File Upload 엔트리 id 생성
|
|
@@ -83,13 +91,22 @@ export const useInputFileContext = ({
|
|
|
83
91
|
* Input Hook; File Upload 엔트리 상태
|
|
84
92
|
*/
|
|
85
93
|
const [entries, setEntries] = useState<InputFileEntry[]>(() =>
|
|
86
|
-
// 초기
|
|
87
|
-
|
|
94
|
+
// 초기 렌더에서는 ref 기반 id generator를 읽지 않고 useId 기반 key를 만든다.
|
|
95
|
+
(initialFiles ?? []).map((file, index) => ({
|
|
96
|
+
id: `${initialEntryId}-${index + 1}`,
|
|
97
|
+
file,
|
|
98
|
+
})),
|
|
88
99
|
);
|
|
89
100
|
/**
|
|
90
101
|
* Input Hook; File Upload initialFiles 시그니처 비교 ref
|
|
91
102
|
*/
|
|
92
|
-
const initialFilesSignatureRef = useRef(
|
|
103
|
+
const initialFilesSignatureRef = useRef(
|
|
104
|
+
(initialFiles ?? [])
|
|
105
|
+
.map(
|
|
106
|
+
file => `${file.name}-${file.size}-${file.lastModified}-${file.type}`,
|
|
107
|
+
)
|
|
108
|
+
.join("|"),
|
|
109
|
+
);
|
|
93
110
|
|
|
94
111
|
/**
|
|
95
112
|
* Input Hook; File Upload 파일 전체 교체 핸들러
|
|
@@ -4,7 +4,7 @@ import clsx from "clsx";
|
|
|
4
4
|
import type {
|
|
5
5
|
ClipboardEvent,
|
|
6
6
|
DragEvent,
|
|
7
|
-
|
|
7
|
+
InputEvent,
|
|
8
8
|
KeyboardEvent,
|
|
9
9
|
MouseEvent,
|
|
10
10
|
} from "react";
|
|
@@ -122,7 +122,7 @@ const InputDateTrigger = forwardRef<
|
|
|
122
122
|
event.preventDefault();
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
-
const handleBeforeInput = (event:
|
|
125
|
+
const handleBeforeInput = (event: InputEvent<HTMLInputElement>) => {
|
|
126
126
|
if (shouldBlockTyping) {
|
|
127
127
|
event.preventDefault();
|
|
128
128
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import clsx from "clsx";
|
|
2
2
|
import { forwardRef, useState } from "react";
|
|
3
3
|
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ComponentRef } from "react";
|
|
5
5
|
import { RadioCard } from "./RadioCard";
|
|
6
6
|
import type { RadioCardGroupProps } from "../types";
|
|
7
7
|
|
|
@@ -23,7 +23,7 @@ const RADIO_CARD_GROUP_CLASSNAME = "radio-card-group";
|
|
|
23
23
|
* <RadioCardGroup options={[{ id: "farm-1", title: "농장" }]} value="farm-1" />
|
|
24
24
|
*/
|
|
25
25
|
const RadioCardGroup = forwardRef<
|
|
26
|
-
|
|
26
|
+
ComponentRef<typeof RadixRadioGroup.Root>,
|
|
27
27
|
RadioCardGroupProps
|
|
28
28
|
>(function RadioCardGroup(
|
|
29
29
|
{
|
|
@@ -14,7 +14,7 @@ import SegmentedControlLabel from "./Label";
|
|
|
14
14
|
* @param {SegmentedControlValue | undefined} props.selectedValue 현재 선택된 값.
|
|
15
15
|
* @param {number} props.focusableIndex 포커스를 받을 기본 index.
|
|
16
16
|
* @param {number} props.fallbackIndex 첫 번째 활성화 index.
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {RefObject<Array<HTMLButtonElement | null>>} props.itemRefs 버튼 ref 목록.
|
|
18
18
|
* @param {(value: SegmentedControlValue | undefined) => void} props.onSelect 값 변경 콜백.
|
|
19
19
|
* @param {(index: number) => void} props.onFocusItemAt index 포커스 함수.
|
|
20
20
|
* @param {(event: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => void} props.onArrowNavigate 화살표 키 처리 콜백.
|
|
@@ -3,8 +3,8 @@ import type {
|
|
|
3
3
|
FocusEvent,
|
|
4
4
|
KeyboardEvent,
|
|
5
5
|
MouseEvent,
|
|
6
|
-
MutableRefObject,
|
|
7
6
|
PointerEvent,
|
|
7
|
+
RefObject,
|
|
8
8
|
} from "react";
|
|
9
9
|
import type { SlotTextProps } from "../../slot";
|
|
10
10
|
|
|
@@ -67,7 +67,7 @@ export interface SegmentedControlListProps {
|
|
|
67
67
|
selectedValue?: SegmentedControlValue;
|
|
68
68
|
focusableIndex: number;
|
|
69
69
|
fallbackIndex: number;
|
|
70
|
-
itemRefs:
|
|
70
|
+
itemRefs: RefObject<Array<HTMLButtonElement | null>>;
|
|
71
71
|
onSelect: (value: SegmentedControlValue | undefined) => void;
|
|
72
72
|
onFocusItemAt: (index: number) => void;
|
|
73
73
|
onArrowNavigate: (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
UseSelectDropdownOpenStateParams,
|
|
@@ -38,17 +38,10 @@ export const useSelectDropdownOpenState = ({
|
|
|
38
38
|
defaultOpen ?? false,
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
// 3)
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (isControlled) return;
|
|
44
|
-
|
|
45
|
-
setUncontrolledOpen(defaultOpen ?? false);
|
|
46
|
-
}, [defaultOpen, isControlled]);
|
|
47
|
-
|
|
48
|
-
// 4) 최종 open state는 제어형 우선, 아니면 내부 state를 사용한다.
|
|
41
|
+
// 3) 최종 open state는 제어형 우선, 아니면 최초 defaultOpen으로 만든 내부 state를 사용한다.
|
|
49
42
|
const resolvedOpen = isControlled ? (open as boolean) : uncontrolledOpen;
|
|
50
43
|
|
|
51
|
-
//
|
|
44
|
+
// 4) setOpen은 내부 state 갱신 + 외부 콜백 브릿지를 동시에 담당한다.
|
|
52
45
|
const setOpen = useCallback(
|
|
53
46
|
(nextOpen: boolean) => {
|
|
54
47
|
if (!isControlled) setUncontrolledOpen(nextOpen);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
|
-
import {
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
5
|
import type { ReactNode } from "react";
|
|
6
6
|
|
|
7
7
|
import { Dropdown } from "../../dropdown/markup";
|
|
@@ -86,10 +86,7 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
86
86
|
const resolvedSize =
|
|
87
87
|
priority !== "primary" && size === "xsmall" ? "small" : size;
|
|
88
88
|
|
|
89
|
-
// 2) custom
|
|
90
|
-
const [customLabelValue, setCustomLabelValue] = useState("");
|
|
91
|
-
|
|
92
|
-
// 3) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
|
|
89
|
+
// 2) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
|
|
93
90
|
const customOptionId = useMemo(() => {
|
|
94
91
|
const existingIdSet = new Set(items.map(option => option.id));
|
|
95
92
|
if (!existingIdSet.has(SELECT_CUSTOM_OPTION_BASE_ID)) {
|
|
@@ -132,38 +129,42 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
132
129
|
}, [items]);
|
|
133
130
|
|
|
134
131
|
// 6) 내부 선택 state를 선언한다.
|
|
135
|
-
const [
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
const [selectionState, setSelectionState] = useState(() => ({
|
|
133
|
+
hasCustomOptions: Boolean(customOptions),
|
|
134
|
+
selectedValue: selectedValueFromItems,
|
|
135
|
+
selectedValueFromItems,
|
|
136
|
+
}));
|
|
138
137
|
|
|
139
138
|
// 7) 외부 items 변경 시 value 규칙으로 내부 선택 state를 재동기화한다.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
if (
|
|
140
|
+
selectionState.hasCustomOptions !== Boolean(customOptions) ||
|
|
141
|
+
!isSameSelectedValue(
|
|
142
|
+
selectionState.selectedValueFromItems,
|
|
143
|
+
selectedValueFromItems,
|
|
144
|
+
)
|
|
145
|
+
) {
|
|
146
|
+
setSelectionState({
|
|
147
|
+
hasCustomOptions: Boolean(customOptions),
|
|
148
|
+
selectedValue:
|
|
143
149
|
customOptions &&
|
|
144
150
|
isSameSelectedValue(
|
|
145
|
-
|
|
151
|
+
selectionState.selectedValue,
|
|
146
152
|
SELECT_CUSTOM_OPTION_VALUE,
|
|
147
153
|
) &&
|
|
148
154
|
selectedValueFromItems === undefined
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
if (isSameSelectedValue(previousSelectedValue, selectedValueFromItems)) {
|
|
154
|
-
return previousSelectedValue;
|
|
155
|
-
}
|
|
156
|
-
return selectedValueFromItems;
|
|
155
|
+
? selectionState.selectedValue
|
|
156
|
+
: selectedValueFromItems,
|
|
157
|
+
selectedValueFromItems,
|
|
157
158
|
});
|
|
158
|
-
}
|
|
159
|
+
}
|
|
159
160
|
|
|
160
161
|
// 8) value 기반으로 현재 선택 option을 계산한다.
|
|
161
162
|
const selectedOption = useMemo(
|
|
162
163
|
() =>
|
|
163
164
|
mergedOptions.find(option =>
|
|
164
|
-
isSameSelectedValue(option.value,
|
|
165
|
+
isSameSelectedValue(option.value, selectionState.selectedValue),
|
|
165
166
|
),
|
|
166
|
-
[mergedOptions,
|
|
167
|
+
[mergedOptions, selectionState.selectedValue],
|
|
167
168
|
);
|
|
168
169
|
|
|
169
170
|
// 9) customOptions가 있는 경우 inputProps.value가 있고 option 매칭이 없으면 custom mode로 간주한다.
|
|
@@ -190,12 +191,16 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
190
191
|
toSelectedValueKey(SELECT_CUSTOM_OPTION_VALUE),
|
|
191
192
|
);
|
|
192
193
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
const [customInputState, setCustomInputState] = useState({
|
|
195
|
+
isActive: false,
|
|
196
|
+
value: "",
|
|
197
|
+
});
|
|
198
|
+
if (customInputState.isActive !== isCustomInputActive) {
|
|
199
|
+
setCustomInputState({
|
|
200
|
+
isActive: isCustomInputActive,
|
|
201
|
+
value: isCustomInputActive ? customInputState.value : "",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
199
204
|
|
|
200
205
|
// 11) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
|
|
201
206
|
const resolvedDisplayLabel =
|
|
@@ -255,7 +260,10 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
255
260
|
});
|
|
256
261
|
}
|
|
257
262
|
|
|
258
|
-
|
|
263
|
+
setSelectionState(previousState => ({
|
|
264
|
+
...previousState,
|
|
265
|
+
selectedValue: option.value,
|
|
266
|
+
}));
|
|
259
267
|
onSelectChange?.(option, previousOption, event);
|
|
260
268
|
}
|
|
261
269
|
|
|
@@ -266,8 +274,8 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
266
274
|
const labelInputValue = isCustomInputActive
|
|
267
275
|
? typeof inputProps?.value === "string" ||
|
|
268
276
|
typeof inputProps?.value === "number"
|
|
269
|
-
? String(inputProps.value) ||
|
|
270
|
-
:
|
|
277
|
+
? String(inputProps.value) || customInputState.value
|
|
278
|
+
: customInputState.value
|
|
271
279
|
: toSelectInputText(resolvedDisplayLabel);
|
|
272
280
|
|
|
273
281
|
// 17) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
|
|
@@ -328,7 +336,12 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
328
336
|
valueStateSource={isCustomInputActive ? "label" : "hidden"}
|
|
329
337
|
// 변경: custom mode 진입 시 label input에 focus를 연결한다.
|
|
330
338
|
shouldFocusInput={isCustomInputActive}
|
|
331
|
-
onLabelChange={
|
|
339
|
+
onLabelChange={value => {
|
|
340
|
+
setCustomInputState(previousState => ({
|
|
341
|
+
...previousState,
|
|
342
|
+
value,
|
|
343
|
+
}));
|
|
344
|
+
}}
|
|
332
345
|
/>
|
|
333
346
|
</SelectTriggerBase>
|
|
334
347
|
</Dropdown.Trigger>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
|
-
import {
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import Container from "../foundation/Container";
|
|
7
7
|
import { Dropdown } from "../../../dropdown/markup";
|
|
@@ -146,26 +146,26 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
146
146
|
* - controlled 모드에서는 source로 사용되지 않는다.
|
|
147
147
|
* - uncontrolled 모드에서는 최종 선택 배열을 내부 state가 소유한다.
|
|
148
148
|
*/
|
|
149
|
-
const [
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
const [selectionState, setSelectionState] = useState(() => ({
|
|
150
|
+
selectedValues: selectedValuesFromOptions,
|
|
151
|
+
selectedValuesFromOptions,
|
|
152
|
+
}));
|
|
152
153
|
|
|
153
154
|
/**
|
|
154
155
|
* 6) items 변경 시 내부 state 정합성 보정
|
|
155
156
|
* - source of truth를 items[].selected(value)로 고정해 외부 선택 상태를 즉시 반영한다.
|
|
156
157
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return nextSelectedValues;
|
|
158
|
+
if (
|
|
159
|
+
!isSameSelectedValueList(
|
|
160
|
+
selectionState.selectedValuesFromOptions,
|
|
161
|
+
selectedValuesFromOptions,
|
|
162
|
+
)
|
|
163
|
+
) {
|
|
164
|
+
setSelectionState({
|
|
165
|
+
selectedValues: selectedValuesFromOptions,
|
|
166
|
+
selectedValuesFromOptions,
|
|
167
167
|
});
|
|
168
|
-
}
|
|
168
|
+
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
171
|
* 7) 최종 선택 value 계산
|
|
@@ -173,10 +173,10 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
173
173
|
*/
|
|
174
174
|
const resolvedSelectedValues = useMemo(
|
|
175
175
|
() =>
|
|
176
|
-
|
|
176
|
+
selectionState.selectedValues.filter(selectedValue =>
|
|
177
177
|
optionMap.has(toSelectedValueKey(selectedValue)),
|
|
178
178
|
),
|
|
179
|
-
[optionMap,
|
|
179
|
+
[optionMap, selectionState.selectedValues],
|
|
180
180
|
);
|
|
181
181
|
const selectedValueKeySet = useMemo(
|
|
182
182
|
() =>
|
|
@@ -340,7 +340,10 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
340
340
|
|
|
341
341
|
onSelectOption?.(selectAllOption, undefined, event);
|
|
342
342
|
if (didChange) {
|
|
343
|
-
|
|
343
|
+
setSelectionState(previousState => ({
|
|
344
|
+
...previousState,
|
|
345
|
+
selectedValues: nextSelectedValues,
|
|
346
|
+
}));
|
|
344
347
|
onSelectChange?.(selectAllOption, undefined, event);
|
|
345
348
|
}
|
|
346
349
|
return;
|
|
@@ -359,7 +362,10 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
359
362
|
: [...resolvedSelectedValues, option.value];
|
|
360
363
|
|
|
361
364
|
onSelectOption?.(option, undefined, event);
|
|
362
|
-
|
|
365
|
+
setSelectionState(previousState => ({
|
|
366
|
+
...previousState,
|
|
367
|
+
selectedValues: nextSelectedValues,
|
|
368
|
+
}));
|
|
363
369
|
onSelectChange?.(option, undefined, event);
|
|
364
370
|
};
|
|
365
371
|
|