@uniai-fe/uds-primitives 0.9.3 → 0.10.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.
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.1",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -66,7 +66,7 @@
66
66
  "@radix-ui/react-checkbox": "^1.3.3",
67
67
  "@radix-ui/react-dropdown-menu": "^2.1.16",
68
68
  "@radix-ui/react-popover": "^1.1.15",
69
- "@radix-ui/react-radio-group": "^1.3.8",
69
+ "@radix-ui/react-radio-group": "^1.4.1",
70
70
  "@radix-ui/react-tabs": "^1.1.13",
71
71
  "@radix-ui/react-tooltip": "^1.2.8",
72
72
  "@radix-ui/react-visually-hidden": "^1.2.4",
@@ -79,19 +79,19 @@
79
79
  "@mantine/dates": "^8.3.18",
80
80
  "@mantine/hooks": "^8.3.18",
81
81
  "@svgr/webpack": "^8.1.0",
82
- "@types/node": "^24.12.3",
82
+ "@types/node": "^24.13.2",
83
83
  "@types/react": "^19.2.17",
84
84
  "@types/react-dom": "^19.2.3",
85
- "eslint": "^9.39.2",
86
- "prettier": "^3.8.4",
87
- "react-hook-form": "^7.80.0",
85
+ "eslint": "^9.39.4",
86
+ "prettier": "^3.8.5",
87
+ "react-hook-form": "^7.81.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",
90
+ "@uniai-fe/eslint-config": "0.4.1",
92
91
  "@uniai-fe/uds-foundation": "0.5.0",
92
+ "@uniai-fe/util-functions": "0.4.3",
93
93
  "@uniai-fe/tsconfig": "0.2.0",
94
- "@uniai-fe/util-functions": "0.4.1"
94
+ "@uniai-fe/next-devkit": "0.4.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,10 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useMemo, useRef, useState } from "react";
5
5
  import type { ReactNode } from "react";
6
6
 
7
7
  import { Dropdown } from "../../dropdown/markup";
8
+ import type { DropdownContainerProps } from "../../dropdown/types";
8
9
  import { SelectTriggerBase, SelectTriggerSelected } from "./foundation";
9
10
  import Container from "./foundation/Container";
10
11
  import { useSelectDropdownOpenState } from "../hooks";
@@ -20,9 +21,21 @@ import {
20
21
  const SELECT_CUSTOM_OPTION_BASE_ID = "__select_custom_input__";
21
22
  const SELECT_CUSTOM_OPTION_VALUE = "CUSTOM";
22
23
 
24
+ /**
25
+ * Radix DropdownMenu.Content가 runtime에서 처리하는 open auto-focus 확장 props
26
+ * @property {(event: Event) => void} [onOpenAutoFocus] open 직후 focus 이동을 제어하는 consumer handler
27
+ */
28
+ type DropdownContainerRuntimeProps = DropdownContainerProps & {
29
+ /**
30
+ * open 직후 focus 이동을 제어하는 consumer handler
31
+ */
32
+ onOpenAutoFocus?: (event: Event) => void;
33
+ };
34
+
23
35
  /**
24
36
  * Select default trigger; 단일 선택 드롭다운을 렌더링한다.
25
37
  * @component
38
+ * @desc enabled selected option이 있으면 open 직후 해당 item을 focus하고 dropdown panel viewport만 nearest 기준으로 정렬한다. consumer open auto-focus handler가 기본 동작을 막으면 consumer 결정을 우선한다.
26
39
  * @param {SelectDefaultComponentProps} props default trigger props
27
40
  * @param {string} [props.className] container className
28
41
  * @param {ReactNode} [props.displayLabel] 강제 표시 라벨
@@ -86,10 +99,7 @@ export function SelectDefault<OptionData = unknown>({
86
99
  const resolvedSize =
87
100
  priority !== "primary" && size === "xsmall" ? "small" : size;
88
101
 
89
- // 2) custom mode의 입력값은 내부 state로 유지한다.
90
- const [customLabelValue, setCustomLabelValue] = useState("");
91
-
92
- // 3) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
102
+ // 2) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
93
103
  const customOptionId = useMemo(() => {
94
104
  const existingIdSet = new Set(items.map(option => option.id));
95
105
  if (!existingIdSet.has(SELECT_CUSTOM_OPTION_BASE_ID)) {
@@ -132,38 +142,42 @@ export function SelectDefault<OptionData = unknown>({
132
142
  }, [items]);
133
143
 
134
144
  // 6) 내부 선택 state를 선언한다.
135
- const [uncontrolledSelectedValue, setUncontrolledSelectedValue] = useState<
136
- string | number | undefined
137
- >(() => selectedValueFromItems);
145
+ const [selectionState, setSelectionState] = useState(() => ({
146
+ hasCustomOptions: Boolean(customOptions),
147
+ selectedValue: selectedValueFromItems,
148
+ selectedValueFromItems,
149
+ }));
138
150
 
139
151
  // 7) 외부 items 변경 시 value 규칙으로 내부 선택 state를 재동기화한다.
140
- useEffect(() => {
141
- setUncontrolledSelectedValue(previousSelectedValue => {
142
- if (
152
+ if (
153
+ selectionState.hasCustomOptions !== Boolean(customOptions) ||
154
+ !isSameSelectedValue(
155
+ selectionState.selectedValueFromItems,
156
+ selectedValueFromItems,
157
+ )
158
+ ) {
159
+ setSelectionState({
160
+ hasCustomOptions: Boolean(customOptions),
161
+ selectedValue:
143
162
  customOptions &&
144
163
  isSameSelectedValue(
145
- previousSelectedValue,
164
+ selectionState.selectedValue,
146
165
  SELECT_CUSTOM_OPTION_VALUE,
147
166
  ) &&
148
167
  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;
168
+ ? selectionState.selectedValue
169
+ : selectedValueFromItems,
170
+ selectedValueFromItems,
157
171
  });
158
- }, [customOptions, selectedValueFromItems]);
172
+ }
159
173
 
160
174
  // 8) value 기반으로 현재 선택 option을 계산한다.
161
175
  const selectedOption = useMemo(
162
176
  () =>
163
177
  mergedOptions.find(option =>
164
- isSameSelectedValue(option.value, uncontrolledSelectedValue),
178
+ isSameSelectedValue(option.value, selectionState.selectedValue),
165
179
  ),
166
- [mergedOptions, uncontrolledSelectedValue],
180
+ [mergedOptions, selectionState.selectedValue],
167
181
  );
168
182
 
169
183
  // 9) customOptions가 있는 경우 inputProps.value가 있고 option 매칭이 없으면 custom mode로 간주한다.
@@ -190,12 +204,16 @@ export function SelectDefault<OptionData = unknown>({
190
204
  toSelectedValueKey(SELECT_CUSTOM_OPTION_VALUE),
191
205
  );
192
206
 
193
- useEffect(() => {
194
- // 변경: custom mode에서 일반 옵션으로 이탈하면 내부 직접 입력값을 초기화한다.
195
- if (!isCustomInputActive) {
196
- setCustomLabelValue("");
197
- }
198
- }, [isCustomInputActive]);
207
+ const [customInputState, setCustomInputState] = useState({
208
+ isActive: false,
209
+ value: "",
210
+ });
211
+ if (customInputState.isActive !== isCustomInputActive) {
212
+ setCustomInputState({
213
+ isActive: isCustomInputActive,
214
+ value: isCustomInputActive ? customInputState.value : "",
215
+ });
216
+ }
199
217
 
200
218
  // 11) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
201
219
  const resolvedDisplayLabel =
@@ -212,6 +230,42 @@ export function SelectDefault<OptionData = unknown>({
212
230
  // 13) disabled/readOnly 상태에서는 open/option 선택 인터랙션을 모두 차단한다.
213
231
  const isInteractionBlocked = disabled || readOnly;
214
232
 
233
+ // 13-1) panel과 enabled selected item을 runtime focus/viewport 정렬 대상으로 추적한다.
234
+ const dropdownPanelRef = useRef<HTMLDivElement>(null);
235
+ const selectedOptionRef = useRef<HTMLDivElement>(null);
236
+
237
+ // 13-2) consumer handler → selected focus → panel-only nearest scroll 순서를 보존한다.
238
+ const onOpenAutoFocus = (event: Event) => {
239
+ // consumer가 명시적으로 기본 동작을 막으면 내부 focus/scroll을 적용하지 않는다.
240
+ (
241
+ dropdownOptions?.containerProps as
242
+ DropdownContainerRuntimeProps | undefined
243
+ )?.onOpenAutoFocus?.(event);
244
+ if (event.defaultPrevented) {
245
+ return;
246
+ }
247
+
248
+ const panel = dropdownPanelRef.current;
249
+ const selectedItem = selectedOptionRef.current;
250
+ // selected가 없거나 disabled이면 ref가 없으므로 Radix 기본 focus 동작을 유지한다.
251
+ if (!panel || !selectedItem) {
252
+ return;
253
+ }
254
+
255
+ // Radix의 panel focus를 막고 outer container/document scroll 없이 selected item만 focus한다.
256
+ event.preventDefault();
257
+ selectedItem.focus({ preventScroll: true });
258
+
259
+ // viewport 밖으로 벗어난 거리만 panel.scrollTop에 반영해 nearest 정렬을 수행한다.
260
+ const panelRect = panel.getBoundingClientRect();
261
+ const selectedItemRect = selectedItem.getBoundingClientRect();
262
+ if (selectedItemRect.top < panelRect.top) {
263
+ panel.scrollTop -= panelRect.top - selectedItemRect.top;
264
+ } else if (selectedItemRect.bottom > panelRect.bottom) {
265
+ panel.scrollTop += selectedItemRect.bottom - panelRect.bottom;
266
+ }
267
+ };
268
+
215
269
  // 14) open 상태 변경 처리: 차단 상태면 닫힘 고정, 아니면 nextOpen 반영.
216
270
  const handleOpenChange = (nextOpen: boolean) => {
217
271
  if (isInteractionBlocked) {
@@ -255,7 +309,10 @@ export function SelectDefault<OptionData = unknown>({
255
309
  });
256
310
  }
257
311
 
258
- setUncontrolledSelectedValue(option.value);
312
+ setSelectionState(previousState => ({
313
+ ...previousState,
314
+ selectedValue: option.value,
315
+ }));
259
316
  onSelectChange?.(option, previousOption, event);
260
317
  }
261
318
 
@@ -266,8 +323,8 @@ export function SelectDefault<OptionData = unknown>({
266
323
  const labelInputValue = isCustomInputActive
267
324
  ? typeof inputProps?.value === "string" ||
268
325
  typeof inputProps?.value === "number"
269
- ? String(inputProps.value) || customLabelValue
270
- : customLabelValue
326
+ ? String(inputProps.value) || customInputState.value
327
+ : customInputState.value
271
328
  : toSelectInputText(resolvedDisplayLabel);
272
329
 
273
330
  // 17) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
@@ -328,15 +385,24 @@ export function SelectDefault<OptionData = unknown>({
328
385
  valueStateSource={isCustomInputActive ? "label" : "hidden"}
329
386
  // 변경: custom mode 진입 시 label input에 focus를 연결한다.
330
387
  shouldFocusInput={isCustomInputActive}
331
- onLabelChange={setCustomLabelValue}
388
+ onLabelChange={value => {
389
+ setCustomInputState(previousState => ({
390
+ ...previousState,
391
+ value,
392
+ }));
393
+ }}
332
394
  />
333
395
  </SelectTriggerBase>
334
396
  </Dropdown.Trigger>
335
397
  <Dropdown.Container
336
398
  {...dropdownOptions?.containerProps}
399
+ ref={dropdownPanelRef}
337
400
  size={dropdownOptions?.size ?? resolvedSize}
338
401
  width={dropdownOptions?.width ?? "match"}
339
402
  minWidth={dropdownOptions?.minWidth}
403
+ {...({
404
+ onOpenAutoFocus,
405
+ } as DropdownContainerProps)}
340
406
  >
341
407
  <Dropdown.Menu.List {...dropdownOptions?.menuListProps}>
342
408
  {mergedOptions.length > 0 ? (
@@ -344,6 +410,16 @@ export function SelectDefault<OptionData = unknown>({
344
410
  {mergedOptions.map(option => (
345
411
  <Dropdown.Menu.Item
346
412
  key={option.id}
413
+ // disabled option은 초기 focus 대상에서 제외하고 현재 단일 selected item만 추적한다.
414
+ ref={
415
+ !option.disabled &&
416
+ isSameSelectedValue(
417
+ resolvedSelectedOption?.value,
418
+ option.value,
419
+ )
420
+ ? selectedOptionRef
421
+ : undefined
422
+ }
347
423
  label={option.label}
348
424
  description={option.description}
349
425
  disabled={option.disabled}
@@ -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