@uniai-fe/uds-primitives 0.3.6 → 0.3.8

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.
@@ -2,6 +2,7 @@
2
2
  /* Layout presets */
3
3
  --input-width: 100%;
4
4
  --input-flex: 0 1 auto;
5
+ --input-layout-gap: var(--spacing-gap-3);
5
6
 
6
7
  /* Input sizing tokens; Button 변수 규칙과 동일한 prefix 패턴을 맞춘다. */
7
8
  --input-default-height-small: var(--theme-size-medium-1);
@@ -10,10 +11,16 @@
10
11
  --input-tertiary-height-base: calc(var(--theme-size-medium-2) + 24px);
11
12
  --input-default-padding-inline: var(--spacing-padding-6);
12
13
  --input-default-padding-block: var(--spacing-padding-4);
14
+ --input-secondary-padding-block: var(--spacing-padding-4);
13
15
  --input-default-gap: var(--spacing-gap-4);
16
+ --input-affix-gap: var(--spacing-gap-3);
17
+ --input-utility-gap: var(--spacing-gap-2);
18
+ --input-tertiary-row-gap: var(--spacing-gap-1);
19
+ --input-tertiary-control-row-gap: var(--spacing-gap-1);
14
20
  --input-default-radius-base: var(--theme-radius-large-1);
15
21
  --input-tertiary-radius-base: var(--theme-radius-large-2);
16
22
  --input-table-radius-base: 0;
23
+ --input-tertiary-element-min-height: var(--theme-size-medium-2);
17
24
  --input-table-text-small-size: var(--font-body-xxsmall-size);
18
25
  --input-table-text-small-line-height: var(--font-body-xxsmall-line-height);
19
26
  --input-table-text-small-weight: var(--font-body-xxsmall-weight);
@@ -28,14 +35,27 @@
28
35
  --input-label-color: var(--color-label-standard);
29
36
  --input-label-accent-color: var(--color-primary-default);
30
37
  --input-label-error-color: var(--color-error);
38
+ --input-label-font-size: var(--font-label-small-size);
39
+ --input-label-line-height: var(--font-label-small-line-height);
40
+ --input-label-font-weight: var(--font-label-small-weight);
31
41
  --input-helper-color: var(--color-label-neutral);
32
42
  --input-helper-success-color: var(--color-success);
33
43
  --input-helper-error-color: var(--color-error);
34
44
  --input-helper-disabled-color: var(--color-label-disabled);
45
+ --input-helper-font-size: var(--font-label-small-size);
46
+ --input-helper-line-height: var(--font-label-small-line-height);
35
47
 
36
48
  /* Text & placeholder colors */
37
49
  --input-text-color: var(--color-label-strong);
50
+ --input-text-readonly-color: var(--color-label-strong);
51
+ --input-text-disabled-color: var(--color-label-disabled);
38
52
  --input-placeholder-color: var(--color-label-alternative);
53
+ --input-placeholder-disabled-color: var(--color-label-disabled);
54
+ --input-placeholder-readonly-color: var(--input-placeholder-disabled-color);
55
+ --input-font-size: var(--font-body-medium-size);
56
+ --input-line-height: var(--font-body-medium-line-height);
57
+ --input-font-weight: 400;
58
+ --input-letter-spacing: 0px;
39
59
 
40
60
  /* Border tokens */
41
61
  --input-border-color: var(--color-border-standard-cool-gray);
@@ -44,6 +64,8 @@
44
64
  --input-border-active-color: var(--color-blue-80);
45
65
  --input-border-success-color: var(--color-blue-80);
46
66
  --input-border-table-default-color: transparent;
67
+ --input-border-table-disabled-color: var(--input-border-table-default-color);
68
+ --input-border-table-readonly-color: var(--input-border-table-disabled-color);
47
69
  /* error는 Figma 44% alpha */
48
70
  --input-border-error-color: rgba(218, 29, 11, 0.44); // --color-feedback-error
49
71
  --input-border-disabled-color: var(--color-border-standard-cool-gray);
@@ -53,4 +75,14 @@
53
75
  --input-surface-color: var(--color-common-100);
54
76
  --input-surface-muted-color: var(--color-neutral-99);
55
77
  --input-surface-disabled-color: var(--color-neutral-95);
78
+ --input-table-surface-color: transparent;
79
+ --input-table-surface-disabled-color: var(--input-table-surface-color);
80
+ --input-table-surface-readonly-color: var(
81
+ --input-table-surface-disabled-color
82
+ );
83
+ --input-secondary-surface-color: transparent;
84
+
85
+ /* Status/affix colors */
86
+ --input-status-error-color: var(--color-error);
87
+ --input-status-success-color: var(--color-primary-default);
56
88
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useEffect, useMemo, useState } from "react";
5
5
 
6
6
  import type { DropdownSize } from "../../dropdown/types";
7
7
  import { Dropdown } from "../../dropdown/markup";
@@ -11,12 +11,20 @@ import { useSelectDropdownOpenState } from "../hooks";
11
11
  import type { SelectDropdownOption } from "../types/option";
12
12
  import type { SelectDefaultComponentProps } from "../types/props";
13
13
 
14
+ const isSameIdList = (previousIds: string[], nextIds: string[]) =>
15
+ previousIds.length === nextIds.length &&
16
+ previousIds.every((selectedId, index) => selectedId === nextIds[index]);
17
+
18
+ const normalizeSingleSelectedIds = (selectedIds: string[]) =>
19
+ selectedIds.length > 0 ? [selectedIds[0]] : [];
20
+
14
21
  /**
15
22
  * Select default trigger; 단일 선택 드롭다운을 렌더링한다.
16
23
  * @component
17
24
  * @param {SelectDefaultComponentProps} props default trigger props
18
25
  * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
19
26
  * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
27
+ * @param {(payload: SelectOptionChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
20
28
  * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
21
29
  * @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority 스케일
22
30
  * @param {"small" | "medium" | "large"} [props.size="medium"] size 스케일
@@ -24,6 +32,7 @@ import type { SelectDefaultComponentProps } from "../types/props";
24
32
  * @param {boolean} [props.block] block 여부
25
33
  * @param {FormFieldWidth} [props.width] container width preset
26
34
  * @param {boolean} [props.disabled] disabled 여부
35
+ * @param {boolean} [props.readOnly] readOnly 여부
27
36
  * @param {SelectTriggerButtonType} [props.buttonType] trigger button type
28
37
  * @param {"small" | "medium" | "large"} [props.dropdownSize] dropdown panel size
29
38
  * @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
@@ -48,9 +57,11 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
48
57
  width,
49
58
  isOpen,
50
59
  disabled,
60
+ readOnly,
51
61
  buttonType,
52
62
  options = [],
53
63
  selectedOptionIds,
64
+ onChange,
54
65
  onOptionSelect,
55
66
  dropdownSize,
56
67
  dropdownWidth = "match",
@@ -65,37 +76,209 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
65
76
  },
66
77
  ref,
67
78
  ) => {
68
- // 변경: table priority는 width 미지정 시 기본 full width를 사용한다.
79
+ /**
80
+ * 1) 레이아웃 기본값 계산
81
+ * - table priority는 셀 컨텍스트에서 full width가 기본 동작이므로
82
+ * width 미지정 시 block=true처럼 동작하도록 보정한다.
83
+ * - 이 값은 trigger/container 양쪽에서 동일하게 사용한다.
84
+ */
69
85
  const resolvedBlock =
70
86
  block || (priority === "table" && width === undefined);
71
- const resolvedSelectedIds = selectedOptionIds ?? [];
72
87
 
88
+ /**
89
+ * 2) 선택 상태 모드 결정
90
+ * - selectedOptionIds prop이 들어오면 controlled 모드(외부가 source of truth)
91
+ * - selectedOptionIds prop이 없으면 uncontrolled 모드(내부 state가 source of truth)
92
+ */
93
+ const isSelectionControlled = selectedOptionIds !== undefined;
94
+
95
+ /**
96
+ * 3) option 조회 최적화 맵 생성
97
+ * - 옵션 탐색(find/filter) 반복을 줄이기 위해 id 기반 맵을 만든다.
98
+ * - display label 계산, payload selectedOptions 계산, 렌더 시 선택 확인에 재사용한다.
99
+ */
100
+ const optionMap = useMemo(
101
+ () => new Map(options.map(option => [option.id, option])),
102
+ [options],
103
+ );
104
+
105
+ /**
106
+ * 4) uncontrolled 초기 선택값 계산
107
+ * - options[].selected를 초기 선택 입력으로 사용한다.
108
+ * - single-select이므로 selected=true가 여러 개여도 첫 id 하나만 채택한다.
109
+ */
110
+ const selectedIdsFromOptions = useMemo(() => {
111
+ const initialSelectedIds = options
112
+ .filter(option => option.selected)
113
+ .map(option => option.id);
114
+ return normalizeSingleSelectedIds(initialSelectedIds);
115
+ }, [options]);
116
+
117
+ /**
118
+ * 5) uncontrolled 내부 state 선언
119
+ * - controlled 모드에서는 실제 UI source로 쓰이지 않는다.
120
+ * - uncontrolled 모드에서는 최종 선택 id를 내부 state가 소유한다.
121
+ */
122
+ const [uncontrolledSelectedOptionIds, setUncontrolledSelectedOptionIds] =
123
+ useState<string[]>(() => selectedIdsFromOptions);
124
+
125
+ /**
126
+ * 6) options 변경 시 내부 state 정합성 보정(uncontrolled 전용)
127
+ * - controlled 모드는 외부 selectedOptionIds를 신뢰하므로 내부 state를 건드리지 않는다.
128
+ * - uncontrolled 모드는 옵션 리스트가 바뀌면 stale id를 정리해야 한다.
129
+ * 1) 기존 선택 id가 현재 options에 존재하는지 필터링
130
+ * 2) 남아 있으면 single 정책으로 첫 id 하나만 유지
131
+ * 3) 남지 않으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화
132
+ */
133
+ useEffect(() => {
134
+ if (isSelectionControlled) {
135
+ return;
136
+ }
137
+
138
+ setUncontrolledSelectedOptionIds(previousSelectedIds => {
139
+ const optionIdSet = new Set(options.map(option => option.id));
140
+ const filteredIds = previousSelectedIds.filter(selectedId =>
141
+ optionIdSet.has(selectedId),
142
+ );
143
+ const nextCandidateIds =
144
+ filteredIds.length > 0 ? filteredIds : selectedIdsFromOptions;
145
+ const nextSelectedIds = normalizeSingleSelectedIds(nextCandidateIds);
146
+
147
+ // 선택 결과가 동일하면 기존 배열 참조를 유지해 effect 루프를 방지한다.
148
+ if (isSameIdList(previousSelectedIds, nextSelectedIds)) {
149
+ return previousSelectedIds;
150
+ }
151
+
152
+ return nextSelectedIds;
153
+ });
154
+ }, [isSelectionControlled, options, selectedIdsFromOptions]);
155
+
156
+ /**
157
+ * 7) 최종 선택 id 계산
158
+ * - controlled: selectedOptionIds를 우선 사용
159
+ * - uncontrolled: 내부 state를 사용
160
+ * - single-select invariant 보장을 위해 항상 최대 1개 id만 반환한다.
161
+ */
162
+ const resolvedSelectedIds = useMemo(() => {
163
+ const sourceIds = isSelectionControlled
164
+ ? (selectedOptionIds ?? [])
165
+ : uncontrolledSelectedOptionIds;
166
+ return normalizeSingleSelectedIds(sourceIds);
167
+ }, [
168
+ isSelectionControlled,
169
+ selectedOptionIds,
170
+ uncontrolledSelectedOptionIds,
171
+ ]);
172
+ const selectedIdSet = useMemo(
173
+ () => new Set(resolvedSelectedIds),
174
+ [resolvedSelectedIds],
175
+ );
176
+
177
+ /**
178
+ * 8) 표시 라벨 계산
179
+ * - 외부 displayLabel이 있으면 최우선 사용
180
+ * - 없으면 현재 선택 id 기준으로 optionMap에서 label을 조회한다.
181
+ * - optionMap을 사용해 O(1) 조회로 단순화한다.
182
+ */
73
183
  const resolvedDisplayLabel =
74
184
  displayLabel ??
75
185
  (resolvedSelectedIds.length > 0
76
- ? options.find(option => option.id === resolvedSelectedIds[0])?.label
186
+ ? optionMap.get(resolvedSelectedIds[0])?.label
77
187
  : undefined);
78
188
 
189
+ /**
190
+ * 9) placeholder/label 표시 상태 계산
191
+ * - null/undefined/빈 문자열이면 placeholder로 간주한다.
192
+ */
79
193
  const hasLabel =
80
194
  resolvedDisplayLabel !== undefined &&
81
195
  resolvedDisplayLabel !== null &&
82
196
  resolvedDisplayLabel !== "";
83
197
 
198
+ /**
199
+ * 10) dropdown open 상태 관리
200
+ * - open/defaultOpen/onOpenChange 계약은 기존 useSelectDropdownOpenState를 그대로 사용한다.
201
+ * - 내부 state와 controlled open 상태를 동시에 지원한다.
202
+ */
84
203
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
85
204
  open: open ?? isOpen,
86
205
  defaultOpen,
87
206
  onOpenChange,
88
207
  });
89
- // 변경: outside close는 Radix onOpenChange 기본 동작을 사용한다.
90
208
 
209
+ /**
210
+ * 11) 상호작용 차단 조건 계산
211
+ * - disabled/readOnly일 때는 open 토글과 option select를 모두 차단한다.
212
+ */
213
+ const isInteractionBlocked = disabled || readOnly;
214
+
215
+ /**
216
+ * 12) open 상태 변경 핸들러
217
+ * - 차단 상태면 강제로 닫힘(false) 유지
218
+ * - 허용 상태면 전달받은 nextOpen 반영
219
+ */
220
+ const handleOpenChange = (nextOpen: boolean) => {
221
+ if (isInteractionBlocked) {
222
+ setOpen(false);
223
+ return;
224
+ }
225
+ setOpen(nextOpen);
226
+ };
227
+
228
+ /**
229
+ * 13) option 선택 처리(single)
230
+ * - single-select 정책: 클릭된 option 하나를 최종 선택값으로 고정
231
+ * - uncontrolled 모드면 내부 state 갱신
232
+ * - onChange(payload)로 현재 선택 결과 전체를 전달
233
+ * - legacy onOptionSelect는 하위호환으로 유지
234
+ */
91
235
  const handleOptionSelect = (option: SelectDropdownOption) => {
236
+ if (isInteractionBlocked) {
237
+ return;
238
+ }
239
+
240
+ const nextSelectedOptionIds = [option.id];
241
+ const nextSelectedOptions = nextSelectedOptionIds
242
+ .map(selectedOptionId => optionMap.get(selectedOptionId))
243
+ .filter(
244
+ (
245
+ selectedOption,
246
+ ): selectedOption is NonNullable<typeof selectedOption> =>
247
+ Boolean(selectedOption),
248
+ );
249
+
250
+ if (!isSelectionControlled) {
251
+ setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
252
+ }
253
+
254
+ onChange?.({
255
+ mode: "single",
256
+ selectedOptionIds: nextSelectedOptionIds,
257
+ selectedValues: nextSelectedOptions.map(
258
+ selectedOption => selectedOption.value,
259
+ ),
260
+ selectedOptions: nextSelectedOptions,
261
+ currentOption: option,
262
+ isSelected: true,
263
+ });
264
+
92
265
  onOptionSelect?.(option);
93
266
  setOpen(false);
94
267
  };
95
268
 
269
+ /**
270
+ * 14) dropdown 패널 렌더링 파생값
271
+ * - panelSize: trigger size와 동일 축을 기본으로 사용
272
+ * - hasOptions: empty 상태 분기
273
+ */
96
274
  const panelSize = (dropdownSize ?? size) as DropdownSize;
97
275
  const hasOptions = options.length > 0;
98
276
 
277
+ /**
278
+ * 15) 렌더
279
+ * - Container → Dropdown.Root → Trigger → Container → Menu.List depth 유지
280
+ * - empty 상태는 별도 구조를 만들지 않고 Dropdown.Menu.Item을 disabled로 재사용
281
+ */
99
282
  return (
100
283
  <Container
101
284
  className={clsx("select-trigger-container", className)}
@@ -104,7 +287,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
104
287
  >
105
288
  <Dropdown.Root
106
289
  open={dropdownOpen}
107
- onOpenChange={setOpen}
290
+ onOpenChange={handleOpenChange}
108
291
  modal={false}
109
292
  {...dropdownRootProps}
110
293
  >
@@ -118,6 +301,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
118
301
  block={resolvedBlock}
119
302
  open={dropdownOpen}
120
303
  disabled={disabled}
304
+ readOnly={readOnly}
121
305
  buttonType={buttonType}
122
306
  {...rest}
123
307
  >
@@ -146,7 +330,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
146
330
  left={option.left}
147
331
  right={option.right}
148
332
  multiple={Boolean(option.multiple)}
149
- isSelected={resolvedSelectedIds.includes(option.id)}
333
+ isSelected={selectedIdSet.has(option.id)}
150
334
  onSelect={event => {
151
335
  if (option.disabled) {
152
336
  event.preventDefault();
@@ -18,6 +18,7 @@ import type { SelectTriggerBaseProps } from "../../types/trigger";
18
18
  * @param {boolean} [props.block=false] block 레이아웃 여부
19
19
  * @param {boolean} [props.multiple=false] multi select 여부
20
20
  * @param {boolean} [props.disabled=false] disabled 여부
21
+ * @param {boolean} [props.readOnly=false] readOnly 여부
21
22
  * @param {ElementType} [props.as="button"] polymorphic 태그
22
23
  * @param {"button" | "submit" | "reset"} [props.buttonType="button"] native button type
23
24
  * @param {string} [props.className] trigger className
@@ -37,6 +38,7 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
37
38
  block = false,
38
39
  multiple = false,
39
40
  disabled = false,
41
+ readOnly = false,
40
42
  as = "button",
41
43
  buttonType = "button",
42
44
  className,
@@ -46,6 +48,7 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
46
48
  ref,
47
49
  ) => {
48
50
  const Icon = SelectIcon.Chevron[priority][size];
51
+ const resolvedState = disabled || readOnly ? "disabled" : state;
49
52
  const {
50
53
  ["aria-haspopup"]: ariaHasPopup,
51
54
  ["aria-expanded"]: ariaExpanded,
@@ -56,10 +59,13 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
56
59
  className: clsx("select-button", className),
57
60
  "data-priority": priority,
58
61
  "data-size": size,
59
- "data-state": state,
62
+ // 변경: readOnly도 disabled와 동일한 시각 상태를 사용하고 텍스트 컬러만 별도 스타일로 분리한다.
63
+ "data-state": resolvedState,
64
+ "data-readonly": readOnly ? "true" : undefined,
60
65
  "data-open": open || undefined,
61
66
  "data-multiple": multiple || undefined,
62
67
  "data-block": block ? "true" : undefined,
68
+ "aria-readonly": readOnly ? "true" : undefined,
63
69
  "aria-haspopup": ariaHasPopup ?? "listbox",
64
70
  "aria-expanded": ariaExpanded ?? open,
65
71
  ...restProps,