@uniai-fe/uds-primitives 0.3.25 → 0.3.27

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.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef, useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useMemo, useState } from "react";
5
5
  import type { ReactNode } from "react";
6
6
 
7
7
  import { Dropdown } from "../../dropdown/markup";
@@ -62,270 +62,260 @@ const SELECT_CUSTOM_OPTION_VALUE = "CUSTOM";
62
62
  * placeholder="품목을 선택하세요"
63
63
  * />
64
64
  */
65
- const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
66
- (
67
- {
68
- className,
69
- displayLabel,
70
- placeholder,
71
- priority = "primary",
72
- size = "medium",
73
- state = "default",
74
- block,
75
- width,
76
- disabled,
77
- readOnly,
78
- buttonType,
79
- items,
80
- onSelectOption,
81
- onSelectChange,
82
- dropdownOptions,
83
- open,
84
- defaultOpen,
85
- onOpen,
86
- triggerProps,
87
- register,
88
- customRegister,
89
- customOptions,
90
- inputProps,
91
- },
92
- ref,
93
- ) => {
94
- // 1) table priority + width 미지정 조합에서는 block을 기본 동작으로 사용한다.
95
- const resolvedBlock =
96
- block || (priority === "table" && width === undefined);
97
- // 1-1) xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
98
- const resolvedSize =
99
- priority !== "primary" && size === "xsmall" ? "small" : size;
100
-
101
- // 2) custom mode의 입력값은 내부 state로 유지한다.
102
- const [customLabelValue, setCustomLabelValue] = useState("");
103
-
104
- // 3) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
105
- const customOptionId = useMemo(() => {
106
- const existingIdSet = new Set(items.map(option => option.id));
107
- if (!existingIdSet.has(SELECT_CUSTOM_OPTION_BASE_ID)) {
108
- return SELECT_CUSTOM_OPTION_BASE_ID;
109
- }
110
-
111
- let offset = 1;
112
- let nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
113
- while (existingIdSet.has(nextId)) {
114
- offset += 1;
115
- nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
65
+ export function SelectDefault<OptionData = unknown>({
66
+ className,
67
+ displayLabel,
68
+ placeholder,
69
+ priority = "primary",
70
+ size = "medium",
71
+ state = "default",
72
+ block,
73
+ width,
74
+ disabled,
75
+ readOnly,
76
+ buttonType,
77
+ items,
78
+ onSelectOption,
79
+ onSelectChange,
80
+ dropdownOptions,
81
+ open,
82
+ defaultOpen,
83
+ onOpen,
84
+ triggerProps,
85
+ register,
86
+ customRegister,
87
+ customOptions,
88
+ inputProps,
89
+ }: SelectDefaultComponentProps<OptionData>) {
90
+ // 1) table priority + width 미지정 조합에서는 block을 기본 동작으로 사용한다.
91
+ const resolvedBlock = block || (priority === "table" && width === undefined);
92
+ // 1-1) xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
93
+ const resolvedSize =
94
+ priority !== "primary" && size === "xsmall" ? "small" : size;
95
+
96
+ // 2) custom mode의 입력값은 내부 state로 유지한다.
97
+ const [customLabelValue, setCustomLabelValue] = useState("");
98
+
99
+ // 3) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
100
+ const customOptionId = useMemo(() => {
101
+ const existingIdSet = new Set(items.map(option => option.id));
102
+ if (!existingIdSet.has(SELECT_CUSTOM_OPTION_BASE_ID)) {
103
+ return SELECT_CUSTOM_OPTION_BASE_ID;
104
+ }
105
+
106
+ let offset = 1;
107
+ let nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
108
+ while (existingIdSet.has(nextId)) {
109
+ offset += 1;
110
+ nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
111
+ }
112
+ return nextId;
113
+ }, [items]);
114
+
115
+ // 4) customOptions가 있으면 dropdown 마지막에 "직접 입력" 항목을 주입한다.
116
+ const mergedOptions = useMemo<SelectDropdownOption<OptionData>[]>(() => {
117
+ if (!customOptions) {
118
+ return items;
119
+ }
120
+
121
+ return [
122
+ ...items,
123
+ {
124
+ id: customOptionId,
125
+ value: SELECT_CUSTOM_OPTION_VALUE,
126
+ label: customOptions.optionName ?? "직접 입력",
127
+ // 변경 설명: custom option sentinel은 id/value로만 판별해 OptionData 제네릭을 오염시키지 않는다.
128
+ } satisfies SelectDropdownOption<OptionData>,
129
+ ];
130
+ }, [customOptionId, customOptions, items]);
131
+
132
+ // 5) 선택 id로 option을 즉시 조회할 수 있도록 map을 만든다.
133
+ const optionMap = useMemo(
134
+ () => new Map(mergedOptions.map(option => [option.id, option])),
135
+ [mergedOptions],
136
+ );
137
+
138
+ // 6) uncontrolled 초기 선택값은 items[].selected의 첫 번째 항목만 사용한다.
139
+ const selectedIdsFromItems = useMemo(() => {
140
+ const initialSelectedIds = items
141
+ .filter(option => option.selected)
142
+ .map(option => option.id);
143
+ return normalizeSingleSelectedIds(initialSelectedIds);
144
+ }, [items]);
145
+
146
+ // 7) 내부 선택 state를 선언한다.
147
+ const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>(
148
+ () => selectedIdsFromItems,
149
+ );
150
+
151
+ // 8) 외부 items 변경 시 동일 규칙으로 내부 선택 state를 재동기화한다.
152
+ useEffect(() => {
153
+ setSelectedOptionIds(previousSelectedIds => {
154
+ if (isSameIdList(previousSelectedIds, selectedIdsFromItems)) {
155
+ return previousSelectedIds;
116
156
  }
117
- return nextId;
118
- }, [items]);
119
-
120
- // 4) customOptions가 있으면 dropdown 마지막에 "직접 입력" 항목을 주입한다.
121
- const mergedOptions = useMemo(() => {
122
- if (!customOptions) {
123
- return items;
124
- }
125
-
126
- return [
127
- ...items,
128
- {
129
- id: customOptionId,
130
- value: SELECT_CUSTOM_OPTION_VALUE,
131
- label: customOptions.optionName ?? "직접 입력",
132
- data: { isCustomInput: true },
133
- } satisfies SelectDropdownOption,
134
- ];
135
- }, [customOptionId, customOptions, items]);
136
-
137
- // 5) 선택 id로 option을 즉시 조회할 수 있도록 map을 만든다.
138
- const optionMap = useMemo(
139
- () => new Map(mergedOptions.map(option => [option.id, option])),
140
- [mergedOptions],
141
- );
142
-
143
- // 6) uncontrolled 초기 선택값은 items[].selected의 첫 번째 항목만 사용한다.
144
- const selectedIdsFromItems = useMemo(() => {
145
- const initialSelectedIds = items
146
- .filter(option => option.selected)
147
- .map(option => option.id);
148
- return normalizeSingleSelectedIds(initialSelectedIds);
149
- }, [items]);
150
-
151
- // 7) 내부 선택 state를 선언한다.
152
- const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>(
153
- () => selectedIdsFromItems,
154
- );
155
-
156
- // 8) 외부 items 변경 시 동일 규칙으로 내부 선택 state를 재동기화한다.
157
- useEffect(() => {
158
- setSelectedOptionIds(previousSelectedIds => {
159
- if (isSameIdList(previousSelectedIds, selectedIdsFromItems)) {
160
- return previousSelectedIds;
161
- }
162
- return selectedIdsFromItems;
163
- });
164
- }, [selectedIdsFromItems]);
165
-
166
- // 9) 단일 선택이므로 첫 번째 id를 현재 선택 option으로 본다.
167
- const selectedOption =
168
- selectedOptionIds.length > 0
169
- ? optionMap.get(selectedOptionIds[0])
170
- : undefined;
171
-
172
- // 10) custom mode는 customOptions 존재 + custom option 선택 상태로 판단한다.
173
- const isCustomInputActive = Boolean(
174
- customOptions && selectedOption?.id === customOptionId,
175
- );
176
-
177
- // 11) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
178
- const resolvedDisplayLabel =
179
- displayLabel ?? (isCustomInputActive ? undefined : selectedOption?.label);
180
-
181
- // 12) open 제어형/비제어형 계약은 공용 hook으로 통합 처리한다.
182
- const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
183
- open,
184
- defaultOpen,
185
- onOpen,
157
+ return selectedIdsFromItems;
186
158
  });
187
-
188
- // 13) disabled/readOnly 상태에서는 open/option 선택 인터랙션을 모두 차단한다.
189
- const isInteractionBlocked = disabled || readOnly;
190
-
191
- // 14) open 상태 변경 처리: 차단 상태면 닫힘 고정, 아니면 nextOpen 반영.
192
- const handleOpenChange = (nextOpen: boolean) => {
193
- if (isInteractionBlocked) {
194
- setOpen(false);
195
- return;
196
- }
197
- setOpen(nextOpen);
198
- };
199
-
200
- // 15) option 선택 처리: onSelectOption 호출 후, 변경이 있을 때만 onSelectChange를 호출한다.
201
- const handleOptionSelect = (option: SelectDropdownOption, event: Event) => {
202
- if (isInteractionBlocked) {
203
- return;
204
- }
205
-
206
- const previousOption = selectedOption;
207
- const hasChanged = previousOption?.id !== option.id;
208
-
209
- onSelectOption?.(option, previousOption, event);
210
-
211
- if (hasChanged) {
212
- setSelectedOptionIds([option.id]);
213
- onSelectChange?.(option, previousOption, event);
214
- }
215
-
159
+ }, [selectedIdsFromItems]);
160
+
161
+ // 9) 단일 선택이므로 번째 id를 현재 선택 option으로 본다.
162
+ const selectedOption =
163
+ selectedOptionIds.length > 0
164
+ ? optionMap.get(selectedOptionIds[0])
165
+ : undefined;
166
+
167
+ // 10) custom mode는 customOptions 존재 + custom option 선택 상태로 판단한다.
168
+ const isCustomInputActive = Boolean(
169
+ customOptions && selectedOption?.id === customOptionId,
170
+ );
171
+
172
+ // 11) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
173
+ const resolvedDisplayLabel =
174
+ displayLabel ?? (isCustomInputActive ? undefined : selectedOption?.label);
175
+
176
+ // 12) open 제어형/비제어형 계약은 공용 hook으로 통합 처리한다.
177
+ const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
178
+ open,
179
+ defaultOpen,
180
+ onOpen,
181
+ });
182
+
183
+ // 13) disabled/readOnly 상태에서는 open/option 선택 인터랙션을 모두 차단한다.
184
+ const isInteractionBlocked = disabled || readOnly;
185
+
186
+ // 14) open 상태 변경 처리: 차단 상태면 닫힘 고정, 아니면 nextOpen 반영.
187
+ const handleOpenChange = (nextOpen: boolean) => {
188
+ if (isInteractionBlocked) {
216
189
  setOpen(false);
217
- };
218
-
219
- // 16) label input 값은 custom mode에서만 사용자 입력값/controlled inputProps.value를 사용한다.
220
- const labelInputValue = isCustomInputActive
221
- ? typeof inputProps?.value === "string" ||
222
- typeof inputProps?.value === "number"
223
- ? String(inputProps.value)
224
- : customLabelValue
225
- : toInputText(resolvedDisplayLabel);
226
-
227
- // 17) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
228
- return (
229
- <Container
230
- className={clsx("select-trigger-container", className)}
231
- block={resolvedBlock}
232
- width={width}
190
+ return;
191
+ }
192
+ setOpen(nextOpen);
193
+ };
194
+
195
+ // 15) option 선택 처리: onSelectOption 호출 후, 변경이 있을 때만 onSelectChange를 호출한다.
196
+ const handleOptionSelect = (
197
+ option: SelectDropdownOption<OptionData>,
198
+ event: Event,
199
+ ) => {
200
+ if (isInteractionBlocked) {
201
+ return;
202
+ }
203
+
204
+ const previousOption = selectedOption;
205
+ const hasChanged = previousOption?.id !== option.id;
206
+
207
+ onSelectOption?.(option, previousOption, event);
208
+
209
+ if (hasChanged) {
210
+ setSelectedOptionIds([option.id]);
211
+ onSelectChange?.(option, previousOption, event);
212
+ }
213
+
214
+ setOpen(false);
215
+ };
216
+
217
+ // 16) label input 값은 custom mode에서만 사용자 입력값/controlled inputProps.value를 사용한다.
218
+ const labelInputValue = isCustomInputActive
219
+ ? typeof inputProps?.value === "string" ||
220
+ typeof inputProps?.value === "number"
221
+ ? String(inputProps.value)
222
+ : customLabelValue
223
+ : toInputText(resolvedDisplayLabel);
224
+
225
+ // 17) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
226
+ return (
227
+ <Container
228
+ className={clsx("select-trigger-container", className)}
229
+ block={resolvedBlock}
230
+ width={width}
231
+ >
232
+ <Dropdown.Root
233
+ open={dropdownOpen}
234
+ onOpenChange={handleOpenChange}
235
+ modal={false}
236
+ {...dropdownOptions?.rootProps}
233
237
  >
234
- <Dropdown.Root
235
- open={dropdownOpen}
236
- onOpenChange={handleOpenChange}
237
- modal={false}
238
- {...dropdownOptions?.rootProps}
238
+ <Dropdown.Trigger asChild>
239
+ <SelectTriggerBase
240
+ priority={priority}
241
+ size={resolvedSize}
242
+ state={disabled ? "disabled" : state}
243
+ block={resolvedBlock}
244
+ open={dropdownOpen}
245
+ disabled={disabled}
246
+ readOnly={readOnly}
247
+ buttonType={buttonType}
248
+ {...triggerProps}
249
+ >
250
+ <SelectTriggerSelected
251
+ label={resolvedDisplayLabel}
252
+ placeholder={
253
+ isCustomInputActive
254
+ ? (customOptions?.placeholder ?? toInputText(placeholder))
255
+ : placeholder
256
+ }
257
+ isPlaceholder={
258
+ isCustomInputActive
259
+ ? false
260
+ : resolvedDisplayLabel === undefined ||
261
+ resolvedDisplayLabel === null ||
262
+ resolvedDisplayLabel === ""
263
+ }
264
+ // 변경: custom mode가 아닐 때는 label input을 읽기 전용으로 고정한다.
265
+ readOnly={readOnly || !isCustomInputActive}
266
+ register={register}
267
+ customRegister={isCustomInputActive ? customRegister : undefined}
268
+ inputProps={inputProps}
269
+ valueText={labelInputValue}
270
+ valueFieldValue={
271
+ isCustomInputActive && !customRegister
272
+ ? labelInputValue
273
+ : (selectedOption?.value ?? "")
274
+ }
275
+ // 변경: custom mode 진입 시 label input에 focus를 연결한다.
276
+ shouldFocusInput={isCustomInputActive}
277
+ onLabelChange={setCustomLabelValue}
278
+ />
279
+ </SelectTriggerBase>
280
+ </Dropdown.Trigger>
281
+ <Dropdown.Container
282
+ {...dropdownOptions?.containerProps}
283
+ size={dropdownOptions?.size ?? resolvedSize}
284
+ width={dropdownOptions?.width ?? "match"}
239
285
  >
240
- <Dropdown.Trigger asChild>
241
- <SelectTriggerBase
242
- ref={ref}
243
- priority={priority}
244
- size={resolvedSize}
245
- state={disabled ? "disabled" : state}
246
- block={resolvedBlock}
247
- open={dropdownOpen}
248
- disabled={disabled}
249
- readOnly={readOnly}
250
- buttonType={buttonType}
251
- {...triggerProps}
252
- >
253
- <SelectTriggerSelected
254
- label={resolvedDisplayLabel}
255
- placeholder={
256
- isCustomInputActive
257
- ? (customOptions?.placeholder ?? toInputText(placeholder))
258
- : placeholder
259
- }
260
- isPlaceholder={
261
- isCustomInputActive
262
- ? false
263
- : resolvedDisplayLabel === undefined ||
264
- resolvedDisplayLabel === null ||
265
- resolvedDisplayLabel === ""
266
- }
267
- // 변경: custom mode가 아닐 때는 label input을 읽기 전용으로 고정한다.
268
- readOnly={readOnly || !isCustomInputActive}
269
- register={register}
270
- customRegister={
271
- isCustomInputActive ? customRegister : undefined
272
- }
273
- inputProps={inputProps}
274
- valueText={labelInputValue}
275
- valueFieldValue={
276
- isCustomInputActive && !customRegister
277
- ? labelInputValue
278
- : (selectedOption?.value ?? "")
279
- }
280
- // 변경: custom mode 진입 시 label input에 focus를 연결한다.
281
- shouldFocusInput={isCustomInputActive}
282
- onLabelChange={setCustomLabelValue}
286
+ <Dropdown.Menu.List {...dropdownOptions?.menuListProps}>
287
+ {mergedOptions.length > 0 ? (
288
+ <>
289
+ {mergedOptions.map(option => (
290
+ <Dropdown.Menu.Item
291
+ key={option.id}
292
+ label={option.label}
293
+ description={option.description}
294
+ disabled={option.disabled}
295
+ left={option.left}
296
+ right={option.right}
297
+ multiple={Boolean(option.multiple)}
298
+ isSelected={selectedOption?.id === option.id}
299
+ onSelect={event => {
300
+ if (option.disabled) {
301
+ event.preventDefault();
302
+ return;
303
+ }
304
+ handleOptionSelect(option, event);
305
+ }}
306
+ />
307
+ ))}
308
+ </>
309
+ ) : (
310
+ <Dropdown.Menu.Item
311
+ label={dropdownOptions?.alt ?? "선택할 항목이 없습니다."}
312
+ disabled
313
+ className="dropdown-menu-alt"
283
314
  />
284
- </SelectTriggerBase>
285
- </Dropdown.Trigger>
286
- <Dropdown.Container
287
- {...dropdownOptions?.containerProps}
288
- size={dropdownOptions?.size ?? resolvedSize}
289
- width={dropdownOptions?.width ?? "match"}
290
- >
291
- <Dropdown.Menu.List {...dropdownOptions?.menuListProps}>
292
- {mergedOptions.length > 0 ? (
293
- <>
294
- {mergedOptions.map(option => (
295
- <Dropdown.Menu.Item
296
- key={option.id}
297
- label={option.label}
298
- description={option.description}
299
- disabled={option.disabled}
300
- left={option.left}
301
- right={option.right}
302
- multiple={Boolean(option.multiple)}
303
- isSelected={selectedOption?.id === option.id}
304
- onSelect={event => {
305
- if (option.disabled) {
306
- event.preventDefault();
307
- return;
308
- }
309
- handleOptionSelect(option, event);
310
- }}
311
- />
312
- ))}
313
- </>
314
- ) : (
315
- <Dropdown.Menu.Item
316
- label={dropdownOptions?.alt ?? "선택할 항목이 없습니다."}
317
- disabled
318
- className="dropdown-menu-alt"
319
- />
320
- )}
321
- </Dropdown.Menu.List>
322
- </Dropdown.Container>
323
- </Dropdown.Root>
324
- </Container>
325
- );
326
- },
327
- );
328
-
329
- SelectDefault.displayName = "SelectDefault";
330
-
331
- export { SelectDefault };
315
+ )}
316
+ </Dropdown.Menu.List>
317
+ </Dropdown.Container>
318
+ </Dropdown.Root>
319
+ </Container>
320
+ );
321
+ }