@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.9.3",
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.2.0",
91
- "@uniai-fe/next-devkit": "0.3.0",
92
- "@uniai-fe/uds-foundation": "0.5.0",
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/util-functions": "0.4.1"
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 { useEffect, useState } from "react";
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 [displayedDate, setDisplayedDate] = useState<Date | DateStringValue>(
47
- () => date ?? mapValueToPicker(value) ?? defaultDate ?? new Date(),
48
- );
46
+ const [displayedDateState, setDisplayedDateState] = useState(() => ({
47
+ defaultDate,
48
+ displayedDate: date ?? mapValueToPicker(value) ?? defaultDate ?? new Date(),
49
+ value,
50
+ }));
49
51
 
50
- useEffect(() => {
51
- if (isDisplayedDateControlled) {
52
- return;
53
- }
54
- setDisplayedDate(mapValueToPicker(value) ?? defaultDate ?? new Date());
55
- }, [defaultDate, isDisplayedDateControlled, value]);
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
- setDisplayedDate(nextDate);
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 carousel = useCarousel();
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
- carousel.registerItemCount(itemCount);
42
- }, [carousel, itemCount]);
42
+ registerItemCount(itemCount);
43
+ }, [itemCount, registerItemCount]);
43
44
 
44
45
  const enhancedChildren = childArray.map((child, index) => {
45
- const isFocused = index === carousel.focusIndex;
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={carousel.viewportRef}
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 { CSSProperties } from "react";
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 [phase, setPhase] = useState<DrawerPhase>(open ? "entered" : "exited");
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
- setPhase("entered");
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
- setPhase("exited");
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 renderButtonLike(
146
- asChild,
147
- children,
148
- { ...props, onClick: handleClick },
149
- forwardedRef,
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 [mounted, setMounted] = useState(false);
164
-
165
- useEffect(() => {
166
- setMounted(true);
167
- return () => setMounted(false);
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 renderButtonLike(
457
- asChild,
458
- children,
459
- { ...props, onClick: handleClick },
460
- forwardedRef,
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 { useEffect, useMemo, useState } from "react";
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 [uncontrolledSelectedValues, setUncontrolledSelectedValues] = useState<
86
- DropdownTemplateValue[]
87
- >(() => selectedValuesFromItems);
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
- useEffect(() => {
95
- setUncontrolledSelectedValues(previousSelectedValues => {
96
- const nextSelectedValues = normalizeSelectedValuesByMode(
97
- selectedValuesFromItems,
98
- isMultiple,
99
- );
100
-
101
- // 내용이 동일하면 기존 참조를 재사용해 불필요한 상태 갱신 루프를 차단한다.
102
- if (isSameSelectedValueList(previousSelectedValues, nextSelectedValues)) {
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
- }, [isMultiple, selectedValuesFromItems]);
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
- uncontrolledSelectedValues,
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
- setUncontrolledSelectedValues(nextSelectedValues);
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, useEffect, useMemo, useState } from "react";
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
- // uncontrolled 값이 있고 maxLength가 줄어들면 즉시 잘라낸다.
35
- useEffect(() => {
36
- if (isControlled) {
37
- return;
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 ?? "") : innerDigits;
40
+ const target = isControlled ? (value ?? "") : clampedInnerDigits;
44
41
  return clampDigits(target);
45
- }, [clampDigits, innerDigits, isControlled, value]);
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
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
- // 초기 렌더에서 initialFiles내부 엔트리 형식으로 변환한다.
87
- mapFilesToEntries(initialFiles ?? []),
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
- FormEvent,
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: FormEvent<HTMLInputElement>) => {
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 { ElementRef } from "react";
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
- ElementRef<typeof RadixRadioGroup.Root>,
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 {MutableRefObject<Array<HTMLButtonElement | null>>} props.itemRefs 버튼 ref 목록.
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: MutableRefObject<Array<HTMLButtonElement | null>>;
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, useEffect, useMemo, useState } from "react";
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) defaultOpen 변경 시, 비제어형일 때만 내부 state를 동기화한다.
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
- // 5) setOpen은 내부 state 갱신 + 외부 콜백 브릿지를 동시에 담당한다.
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 { useEffect, useMemo, useState } from "react";
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 mode의 입력값은 내부 state로 유지한다.
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 [uncontrolledSelectedValue, setUncontrolledSelectedValue] = useState<
136
- string | number | undefined
137
- >(() => selectedValueFromItems);
132
+ const [selectionState, setSelectionState] = useState(() => ({
133
+ hasCustomOptions: Boolean(customOptions),
134
+ selectedValue: selectedValueFromItems,
135
+ selectedValueFromItems,
136
+ }));
138
137
 
139
138
  // 7) 외부 items 변경 시 value 규칙으로 내부 선택 state를 재동기화한다.
140
- useEffect(() => {
141
- setUncontrolledSelectedValue(previousSelectedValue => {
142
- if (
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
- previousSelectedValue,
151
+ selectionState.selectedValue,
146
152
  SELECT_CUSTOM_OPTION_VALUE,
147
153
  ) &&
148
154
  selectedValueFromItems === undefined
149
- ) {
150
- // 변경 설명: custom option은 mergedOptions에만 주입되므로 source items 재동기화가 첫 클릭 CUSTOM 상태를 지우지 않게 한다.
151
- return previousSelectedValue;
152
- }
153
- if (isSameSelectedValue(previousSelectedValue, selectedValueFromItems)) {
154
- return previousSelectedValue;
155
- }
156
- return selectedValueFromItems;
155
+ ? selectionState.selectedValue
156
+ : selectedValueFromItems,
157
+ selectedValueFromItems,
157
158
  });
158
- }, [customOptions, selectedValueFromItems]);
159
+ }
159
160
 
160
161
  // 8) value 기반으로 현재 선택 option을 계산한다.
161
162
  const selectedOption = useMemo(
162
163
  () =>
163
164
  mergedOptions.find(option =>
164
- isSameSelectedValue(option.value, uncontrolledSelectedValue),
165
+ isSameSelectedValue(option.value, selectionState.selectedValue),
165
166
  ),
166
- [mergedOptions, uncontrolledSelectedValue],
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
- useEffect(() => {
194
- // 변경: custom mode에서 일반 옵션으로 이탈하면 내부 직접 입력값을 초기화한다.
195
- if (!isCustomInputActive) {
196
- setCustomLabelValue("");
197
- }
198
- }, [isCustomInputActive]);
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
- setUncontrolledSelectedValue(option.value);
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) || customLabelValue
270
- : customLabelValue
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={setCustomLabelValue}
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 { useEffect, useMemo, useState } from "react";
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 [uncontrolledSelectedValues, setUncontrolledSelectedValues] = useState<
150
- Array<string | number>
151
- >(() => selectedValuesFromOptions);
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
- useEffect(() => {
158
- setUncontrolledSelectedValues(previousSelectedValues => {
159
- const nextSelectedValues = selectedValuesFromOptions;
160
-
161
- // 동일한 선택 결과면 기존 배열 참조를 유지해 재렌더 루프를 방지한다.
162
- if (isSameSelectedValueList(previousSelectedValues, nextSelectedValues)) {
163
- return previousSelectedValues;
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
- }, [selectedValuesFromOptions]);
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
- uncontrolledSelectedValues.filter(selectedValue =>
176
+ selectionState.selectedValues.filter(selectedValue =>
177
177
  optionMap.has(toSelectedValueKey(selectedValue)),
178
178
  ),
179
- [optionMap, uncontrolledSelectedValues],
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
- setUncontrolledSelectedValues(nextSelectedValues);
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
- setUncontrolledSelectedValues(nextSelectedValues);
365
+ setSelectionState(previousState => ({
366
+ ...previousState,
367
+ selectedValues: nextSelectedValues,
368
+ }));
363
369
  onSelectChange?.(option, undefined, event);
364
370
  };
365
371