@uniai-fe/uds-primitives 0.2.8 → 0.2.9

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/dist/styles.css CHANGED
@@ -1782,6 +1782,10 @@ figure.chip {
1782
1782
  margin: 0;
1783
1783
  }
1784
1784
 
1785
+ .dropdown-menu-item-trigger.dropdown-menu-alt[data-disabled] {
1786
+ cursor: default;
1787
+ }
1788
+
1785
1789
  .dropdown-menu-item {
1786
1790
  width: 100%;
1787
1791
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,6 +12,16 @@ import DropdownTrigger from "./foundation/Trigger";
12
12
  * Dropdown reference template; trigger/panel/menu 조합을 제공한다.
13
13
  * @component
14
14
  * @param {DropdownTemplateProps} props Dropdown template props
15
+ * @param {ReactNode} props.trigger trigger 요소
16
+ * @param {DropdownTemplateItem[]} props.items 렌더링할 menu item 리스트
17
+ * @param {string[]} [props.selectedIds] 선택된 item id 배열
18
+ * @param {(item: DropdownTemplateItem) => void} [props.onSelect] item 선택 콜백
19
+ * @param {"small" | "medium" | "large"} [props.size="medium"] menu size scale
20
+ * @param {"match" | "fit-content" | "max-content" | string | number} [props.width="match"] panel width 옵션
21
+ * @param {DropdownMenuProps} [props.rootProps] Dropdown.Root 전달 props
22
+ * @param {DropdownContainerProps} [props.containerProps] Dropdown.Container 전달 props
23
+ * @param {DropdownMenuListProps} [props.menuListProps] Dropdown.Menu.List 전달 props
24
+ * @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
15
25
  */
16
26
  const DropdownTemplate = ({
17
27
  trigger,
@@ -23,31 +33,45 @@ const DropdownTemplate = ({
23
33
  rootProps,
24
34
  containerProps,
25
35
  menuListProps,
36
+ alt,
26
37
  }: DropdownTemplateProps) => {
38
+ const hasItems = items.length > 0;
39
+
27
40
  return (
28
41
  <DropdownRoot {...rootProps}>
29
42
  <DropdownTrigger asChild>{trigger}</DropdownTrigger>
30
43
  <DropdownContainer {...containerProps} size={size} width={width}>
31
44
  <DropdownMenuList {...menuListProps}>
32
- {items.map(item => (
45
+ {hasItems ? (
46
+ <>
47
+ {items.map(item => (
48
+ <DropdownMenuItem
49
+ key={item.id}
50
+ label={item.label}
51
+ description={item.description}
52
+ disabled={item.disabled}
53
+ left={item.left}
54
+ right={item.right}
55
+ multiple={item.multiple}
56
+ isSelected={selectedIds?.includes(item.id)}
57
+ onSelect={event => {
58
+ if (item.disabled) {
59
+ event.preventDefault();
60
+ return;
61
+ }
62
+ onSelect?.(item);
63
+ }}
64
+ />
65
+ ))}
66
+ </>
67
+ ) : (
33
68
  <DropdownMenuItem
34
- key={item.id}
35
- label={item.label}
36
- description={item.description}
37
- disabled={item.disabled}
38
- left={item.left}
39
- right={item.right}
40
- multiple={item.multiple}
41
- isSelected={selectedIds?.includes(item.id)}
42
- onSelect={event => {
43
- if (item.disabled) {
44
- event.preventDefault();
45
- return;
46
- }
47
- onSelect?.(item);
48
- }}
69
+ // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
70
+ label={alt ?? "데이터가 없습니다."}
71
+ disabled
72
+ className="dropdown-menu-alt"
49
73
  />
50
- ))}
74
+ )}
51
75
  </DropdownMenuList>
52
76
  </DropdownContainer>
53
77
  </DropdownRoot>
@@ -8,11 +8,23 @@ import type { DropdownContainerProps } from "../../types/props";
8
8
  import { useDropdownContext } from "./Provider";
9
9
 
10
10
  /**
11
- * Dropdown container; trigger width 동기화 및 portal 관리
11
+ * Dropdown Foundation; Container 패널 렌더링 컴포넌트
12
12
  * @component
13
13
  * @param {DropdownContainerProps} props Dropdown container props
14
- * @param {DropdownPanelWidth} [props.width="match"] panel width 옵션
14
+ * @param {React.ReactNode} props.children dropdown panel 콘텐츠
15
+ * @param {string} [props.className] panel className
15
16
  * @param {DropdownSize} [props.size="medium"] option height scale
17
+ * @param {DropdownPanelWidth} [props.width="match"] panel width 옵션
18
+ * @param {HTMLElement | null} [props.portalContainer] portal 컨테이너
19
+ * @param {"start" | "center" | "end"} [props.align="start"] 정렬 기준
20
+ * @param {"top" | "right" | "bottom" | "left"} [props.side="bottom"] 패널 위치
21
+ * @param {number} [props.sideOffset=4] trigger 와 패널 사이 간격
22
+ * @param {number} [props.alignOffset] 정렬 보정값
23
+ * @param {React.CSSProperties} [props.style] 인라인 스타일
24
+ * @example
25
+ * <DropdownContainer width="match">
26
+ * <Dropdown.Menu.List />
27
+ * </DropdownContainer>
16
28
  */
17
29
  const DropdownContainer = forwardRef<HTMLDivElement, DropdownContainerProps>(
18
30
  (
@@ -9,11 +9,20 @@ import { Checkbox } from "../../../checkbox/markup/Checkbox";
9
9
  import type { CheckboxProps } from "../../../checkbox/types";
10
10
 
11
11
  /**
12
- * Dropdown menu item; label/description/slot 구성을 처리한다.
12
+ * Dropdown Foundation; Menu Item 옵션 렌더링 컴포넌트
13
13
  * @component
14
14
  * @param {DropdownMenuItemProps} props dropdown menu option props
15
- * @param {boolean} [props.isSelected] 선택 상태
16
- * @param {boolean} [props.multiple] multi select 스타일 여부
15
+ * @param {React.ReactNode} [props.label] 옵션 라벨
16
+ * @param {React.ReactNode} [props.description] 보조 텍스트
17
+ * @param {React.ReactNode} [props.left] 좌측 콘텐츠
18
+ * @param {React.ReactNode} [props.right] 우측 콘텐츠
19
+ * @param {boolean} [props.isSelected=false] 선택 상태
20
+ * @param {boolean} [props.multiple=false] multi select 스타일 여부
21
+ * @param {CheckboxProps} [props.checkboxProps] multiple 시 checkbox props
22
+ * @param {React.ReactNode} [props.children] label 미지정 시 fallback 콘텐츠
23
+ * @param {string} [props.className] Dropdown item className
24
+ * @example
25
+ * <DropdownMenuItem label="옵션 A" isSelected />
17
26
  */
18
27
  const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
19
28
  (
@@ -33,6 +42,13 @@ const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
33
42
  ref,
34
43
  ) => {
35
44
  const labelContent = label ?? children;
45
+ // 변경: label/children이 string|number일 때만 준비된 label span으로 매핑하고, 그 외 ReactNode는 그대로 렌더링한다.
46
+ const resolvedLabelContent =
47
+ typeof labelContent === "string" || typeof labelContent === "number" ? (
48
+ <span className="dropdown-menu-item-label">{labelContent}</span>
49
+ ) : (
50
+ labelContent
51
+ );
36
52
  const hasDescription = Boolean(description);
37
53
  const shouldRenderCheckbox = multiple && !left;
38
54
 
@@ -84,9 +100,7 @@ const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
84
100
  >
85
101
  {renderLeft()}
86
102
  <span className="dropdown-menu-item-body">
87
- {labelContent ? (
88
- <span className="dropdown-menu-item-label">{labelContent}</span>
89
- ) : null}
103
+ {resolvedLabelContent}
90
104
  {description ? (
91
105
  <span className="dropdown-menu-item-description">
92
106
  {description}
@@ -7,11 +7,18 @@ import type { ReactNode } from "react";
7
7
  import { DropdownProvider } from "./Provider";
8
8
 
9
9
  /**
10
- * Dropdown root; Provider Radix Root를 래핑한다.
10
+ * Dropdown Foundation; Root Provider 래핑 컴포넌트
11
11
  * @component
12
12
  * @param {DropdownMenuProps} props Dropdown Root props
13
13
  * @param {ReactNode} props.children Dropdown 하위 node
14
14
  * @param {boolean} [props.modal=false] Radix modal 모드
15
+ * @param {boolean} [props.open] 제어형 open 상태
16
+ * @param {boolean} [props.defaultOpen] 비제어형 초기 open 상태
17
+ * @param {(open: boolean) => void} [props.onOpenChange] open 변경 콜백
18
+ * @example
19
+ * <DropdownRoot>
20
+ * <Dropdown.Trigger>열기</Dropdown.Trigger>
21
+ * </DropdownRoot>
15
22
  */
16
23
  const DropdownRoot = ({
17
24
  children,
@@ -8,10 +8,16 @@ import { mergeRefs } from "../../utils";
8
8
  import { useDropdownContext } from "./Provider";
9
9
 
10
10
  /**
11
- * Dropdown trigger; trigger ref context에 공유한다.
11
+ * Dropdown Foundation; Trigger ref 공유 컴포넌트
12
12
  * @component
13
13
  * @param {DropdownMenuTriggerProps} props Dropdown trigger props
14
14
  * @param {boolean} [props.asChild=true] asChild 패턴 유지 여부
15
+ * @param {React.ReactNode} props.children Trigger 하위 node
16
+ * @param {string} [props.className] Trigger className
17
+ * @example
18
+ * <DropdownTrigger asChild>
19
+ * <button type="button">열기</button>
20
+ * </DropdownTrigger>
15
21
  */
16
22
  const DropdownTrigger = forwardRef<HTMLElement, DropdownMenuTriggerProps>(
17
23
  ({ asChild = true, children, ...rest }, ref) => {
@@ -47,6 +47,10 @@
47
47
  margin: 0;
48
48
  }
49
49
 
50
+ .dropdown-menu-item-trigger.dropdown-menu-alt[data-disabled] {
51
+ cursor: default;
52
+ }
53
+
50
54
  .dropdown-menu-item {
51
55
  width: 100%;
52
56
  }
@@ -138,6 +138,7 @@ export interface DropdownTemplateItem {
138
138
  * @property {DropdownMenuProps} [rootProps] Root 에 전달할 props
139
139
  * @property {DropdownContainerProps} [containerProps] Container 에 전달할 props
140
140
  * @property {DropdownMenuListProps} [menuListProps] MenuList 에 전달할 props
141
+ * @property {ReactNode} [alt] item이 비어 있을 때 렌더링할 alternate 콘텐츠
141
142
  */
142
143
  export interface DropdownTemplateProps {
143
144
  trigger: ReactNode;
@@ -159,4 +160,8 @@ export interface DropdownTemplateProps {
159
160
  * MenuList 에 전달할 props
160
161
  */
161
162
  menuListProps?: DropdownMenuListProps;
163
+ /**
164
+ * item이 비어 있을 때 렌더링할 alternate 콘텐츠
165
+ */
166
+ alt?: ReactNode;
162
167
  }
@@ -1,45 +1 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
2
-
3
- import type { SelectDropdownBehaviorProps } from "../types/props";
4
-
5
- /**
6
- * Select dropdown open 상태를 제어하는 hook
7
- * @hook
8
- * @param {SelectDropdownBehaviorProps} props open 제어 옵션
9
- * @returns {{
10
- * open: boolean;
11
- * setOpen: (next: boolean) => void;
12
- * isControlled: boolean;
13
- * }} resolved open state
14
- */
15
- export const useSelectDropdownOpenState = ({
16
- open,
17
- defaultOpen,
18
- onOpenChange,
19
- }: SelectDropdownBehaviorProps) => {
20
- const isControlled = useMemo(() => typeof open === "boolean", [open]);
21
- const [uncontrolledOpen, setUncontrolledOpen] = useState(
22
- defaultOpen ?? false,
23
- );
24
-
25
- useEffect(() => {
26
- if (isControlled) {
27
- return;
28
- }
29
- setUncontrolledOpen(defaultOpen ?? false);
30
- }, [defaultOpen, isControlled]);
31
-
32
- const resolvedOpen = isControlled ? (open as boolean) : uncontrolledOpen;
33
-
34
- const setOpen = useCallback(
35
- (nextOpen: boolean) => {
36
- if (!isControlled) {
37
- setUncontrolledOpen(nextOpen);
38
- }
39
- onOpenChange?.(nextOpen);
40
- },
41
- [isControlled, onOpenChange],
42
- );
43
-
44
- return { open: resolvedOpen, setOpen, isControlled };
45
- };
1
+ export * from "./interaction";
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+
5
+ import type {
6
+ UseSelectDropdownOpenStateParams,
7
+ UseSelectDropdownOpenStateReturn,
8
+ } from "../types/interaction";
9
+
10
+ /**
11
+ * Select Hook; Dropdown open 상태 제어 Hook
12
+ * @hook
13
+ * @param {UseSelectDropdownOpenStateParams} params open 제어 옵션
14
+ * @param {boolean} [params.open] 외부 제어형 open 상태
15
+ * @param {boolean} [params.defaultOpen] 비제어형 초기 open 상태
16
+ * @param {(open: boolean) => void} [params.onOpenChange] open 상태 변경 콜백
17
+ * @returns {{
18
+ * open: boolean;
19
+ * setOpen: (next: boolean) => void;
20
+ * isControlled: boolean;
21
+ * }} resolved open state
22
+ * @example
23
+ * const { open, setOpen } = useSelectDropdownOpenState({
24
+ * open: controlledOpen,
25
+ * defaultOpen: false,
26
+ * onOpenChange: onOpenChangeHandler,
27
+ * });
28
+ */
29
+ export const useSelectDropdownOpenState = ({
30
+ open,
31
+ defaultOpen,
32
+ onOpenChange,
33
+ }: UseSelectDropdownOpenStateParams): UseSelectDropdownOpenStateReturn => {
34
+ // 1) 제어형/비제어형 분기 기준을 먼저 확정한다.
35
+ const isControlled = useMemo(() => typeof open === "boolean", [open]);
36
+ // 2) 비제어형일 때만 내부 open state를 소유한다.
37
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(
38
+ defaultOpen ?? false,
39
+ );
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를 사용한다.
49
+ const resolvedOpen = isControlled ? (open as boolean) : uncontrolledOpen;
50
+
51
+ // 5) setOpen은 내부 state 갱신 + 외부 콜백 브릿지를 동시에 담당한다.
52
+ const setOpen = useCallback(
53
+ (nextOpen: boolean) => {
54
+ if (!isControlled) setUncontrolledOpen(nextOpen);
55
+
56
+ onOpenChange?.(nextOpen);
57
+ },
58
+ [isControlled, onOpenChange],
59
+ );
60
+
61
+ return { open: resolvedOpen, setOpen, isControlled };
62
+ };
@@ -22,7 +22,18 @@ import type { SelectDefaultComponentProps } from "../types/props";
22
22
  * @param {"small" | "medium" | "large"} [props.size="medium"] size 스케일
23
23
  * @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
24
24
  * @param {boolean} [props.block] block 여부
25
+ * @param {FormFieldWidth} [props.width] container width preset
25
26
  * @param {boolean} [props.disabled] disabled 여부
27
+ * @param {SelectTriggerButtonType} [props.buttonType] trigger button type
28
+ * @param {"small" | "medium" | "large"} [props.dropdownSize] dropdown panel size
29
+ * @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
30
+ * @param {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [props.dropdownRootProps] Dropdown.Root 전달 props
31
+ * @param {Omit<DropdownContainerProps, "children" | "size" | "width">} [props.dropdownContainerProps] Dropdown.Container 전달 props
32
+ * @param {DropdownMenuListProps} [props.dropdownMenuListProps] Dropdown.Menu.List 전달 props
33
+ * @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
34
+ * @param {boolean} [props.open] controlled open 상태
35
+ * @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
36
+ * @param {(open: boolean) => void} [props.onOpenChange] open 상태 변경 콜백
26
37
  */
27
38
  const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
28
39
  (
@@ -46,6 +57,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
46
57
  dropdownRootProps,
47
58
  dropdownContainerProps,
48
59
  dropdownMenuListProps,
60
+ alt,
49
61
  open,
50
62
  defaultOpen,
51
63
  onOpenChange,
@@ -71,7 +83,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
71
83
  defaultOpen,
72
84
  onOpenChange,
73
85
  });
74
- // Dropdown open 상태를 trigger data-state와 동기화한다.
86
+ // 변경: outside close는 Radix onOpenChange 기본 동작을 사용한다.
75
87
 
76
88
  const handleOptionSelect = (option: SelectDropdownOption) => {
77
89
  onOptionSelect?.(option);
@@ -79,7 +91,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
79
91
  };
80
92
 
81
93
  const panelSize = (dropdownSize ?? size) as DropdownSize;
82
- const shouldRenderDropdown = options.length > 0;
94
+ const hasOptions = options.length > 0;
83
95
 
84
96
  return (
85
97
  <Container
@@ -113,36 +125,45 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
113
125
  />
114
126
  </SelectTriggerBase>
115
127
  </Dropdown.Trigger>
116
- {shouldRenderDropdown ? (
117
- <Dropdown.Container
118
- {...dropdownContainerProps}
119
- size={panelSize}
120
- width={dropdownWidth}
121
- >
122
- <Dropdown.Menu.List {...dropdownMenuListProps}>
123
- {/* Dropdown menu option들을 그대로 매핑해 선택 이벤트를 전달한다. */}
124
- {options.map(option => (
125
- <Dropdown.Menu.Item
126
- key={option.id}
127
- label={option.label}
128
- description={option.description}
129
- disabled={option.disabled}
130
- left={option.left}
131
- right={option.right}
132
- multiple={Boolean(option.multiple)}
133
- isSelected={resolvedSelectedIds.includes(option.id)}
134
- onSelect={event => {
135
- if (option.disabled) {
136
- event.preventDefault();
137
- return;
138
- }
139
- handleOptionSelect(option);
140
- }}
141
- />
142
- ))}
143
- </Dropdown.Menu.List>
144
- </Dropdown.Container>
145
- ) : null}
128
+ <Dropdown.Container
129
+ {...dropdownContainerProps}
130
+ size={panelSize}
131
+ width={dropdownWidth}
132
+ >
133
+ <Dropdown.Menu.List {...dropdownMenuListProps}>
134
+ {hasOptions ? (
135
+ <>
136
+ {/* Dropdown menu option들을 그대로 매핑해 선택 이벤트를 전달한다. */}
137
+ {options.map(option => (
138
+ <Dropdown.Menu.Item
139
+ key={option.id}
140
+ label={option.label}
141
+ description={option.description}
142
+ disabled={option.disabled}
143
+ left={option.left}
144
+ right={option.right}
145
+ multiple={Boolean(option.multiple)}
146
+ isSelected={resolvedSelectedIds.includes(option.id)}
147
+ onSelect={event => {
148
+ if (option.disabled) {
149
+ event.preventDefault();
150
+ return;
151
+ }
152
+ handleOptionSelect(option);
153
+ }}
154
+ />
155
+ ))}
156
+ </>
157
+ ) : (
158
+ <Dropdown.Menu.Item
159
+ // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
160
+ label={alt ?? "선택할 항목이 없습니다."}
161
+ disabled
162
+ className="dropdown-menu-alt"
163
+ />
164
+ )}
165
+ </Dropdown.Menu.List>
166
+ </Dropdown.Container>
146
167
  </Dropdown.Root>
147
168
  </Container>
148
169
  );
@@ -8,16 +8,24 @@ import { SelectIcon } from "./Icon";
8
8
  import type { SelectTriggerBaseProps } from "../../types/trigger";
9
9
 
10
10
  /**
11
- * Select trigger foundation; priority/size/state를 data attribute로 노출하고
12
- * Chevron 아이콘을 자동 연결하는 기본 요소다.
11
+ * Select Foundation; Trigger Base 슬롯 렌더링 컴포넌트
13
12
  * @component
14
13
  * @param {SelectTriggerBaseProps} props trigger base props
15
14
  * @param {"primary" | "secondary"} [props.priority="primary"] 스타일 우선순위
16
15
  * @param {"small" | "medium" | "large"} [props.size="medium"] 높이 스케일
17
16
  * @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
18
- * @param {boolean} [props.multiple] multi select 여부
17
+ * @param {boolean} [props.open=false] dropdown open 상태
18
+ * @param {boolean} [props.block=false] block 레이아웃 여부
19
+ * @param {boolean} [props.multiple=false] multi select 여부
20
+ * @param {boolean} [props.disabled=false] disabled 여부
19
21
  * @param {ElementType} [props.as="button"] polymorphic 태그
20
22
  * @param {"button" | "submit" | "reset"} [props.buttonType="button"] native button type
23
+ * @param {string} [props.className] trigger className
24
+ * @param {React.ReactNode} props.children trigger 콘텐츠
25
+ * @example
26
+ * <SelectTriggerBase open={false} size="medium">
27
+ * <span>옵션 선택</span>
28
+ * </SelectTriggerBase>
21
29
  */
22
30
  const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
23
31
  (
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
+ import { forwardRef } from "react";
4
5
 
5
6
  import type { SelectContainerProps } from "../../types/props";
6
7
  import {
@@ -14,40 +15,42 @@ import {
14
15
  * @param {SelectContainerProps} props Select container props
15
16
  * @param {string} [props.className] 사용자 정의 className
16
17
  * @param {boolean} [props.block] wrapper 전체 폭 확장 여부
18
+ * @param {FormFieldWidth} [props.width] Form.Field width preset
19
+ * @param {CSSProperties} [props.style] wrapper inline style
17
20
  * @param {React.ReactNode} props.children trigger 및 dropdown 콘텐츠
18
21
  */
19
- export default function SelectContainer({
20
- className,
21
- children,
22
- block = false,
23
- width,
24
- style,
25
- ...restProps
26
- }: SelectContainerProps) {
27
- const widthAttr =
28
- width !== undefined
29
- ? getFormFieldWidthAttr(width)
30
- : block
31
- ? "full"
32
- : undefined;
33
- const widthValue =
34
- width !== undefined ? getFormFieldWidthValue(width) : undefined;
35
- const mergedStyle =
36
- widthValue !== undefined
37
- ? { ...(style ?? {}), ["--select-width" as const]: widthValue }
38
- : style;
22
+ const SelectContainer = forwardRef<HTMLDivElement, SelectContainerProps>(
23
+ ({ className, children, block = false, width, style, ...restProps }, ref) => {
24
+ const widthAttr =
25
+ width !== undefined
26
+ ? getFormFieldWidthAttr(width)
27
+ : block
28
+ ? "full"
29
+ : undefined;
30
+ const widthValue =
31
+ width !== undefined ? getFormFieldWidthValue(width) : undefined;
32
+ const mergedStyle =
33
+ widthValue !== undefined
34
+ ? { ...(style ?? {}), ["--select-width" as const]: widthValue }
35
+ : style;
39
36
 
40
- return (
41
- <div
42
- className={clsx("select select-container", className, {
43
- "select-block": block,
44
- })}
45
- data-width={widthAttr}
46
- style={mergedStyle}
47
- {...restProps}
48
- >
49
- {/** dropdown root 및 dropdown menu 등 포함 예정 */}
50
- {children}
51
- </div>
52
- );
53
- }
37
+ return (
38
+ <div
39
+ ref={ref}
40
+ className={clsx("select select-container", className, {
41
+ "select-block": block,
42
+ })}
43
+ data-width={widthAttr}
44
+ style={mergedStyle}
45
+ {...restProps}
46
+ >
47
+ {/** dropdown root 및 dropdown menu 등 포함 예정 */}
48
+ {children}
49
+ </div>
50
+ );
51
+ },
52
+ );
53
+
54
+ SelectContainer.displayName = "SelectContainer";
55
+
56
+ export default SelectContainer;
@@ -17,13 +17,27 @@ import { useSelectDropdownOpenState } from "../../hooks";
17
17
  * @component
18
18
  * @param {SelectMultipleComponentProps} props multi trigger props
19
19
  * @param {SelectMultipleTag[]} [props.tags] 선택된 tag 리스트
20
+ * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
21
+ * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
22
+ * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
20
23
  * @param {React.ReactNode} [props.displayLabel] fallback 라벨
21
24
  * @param {React.ReactNode} [props.placeholder] placeholder 텍스트
22
25
  * @param {"primary" | "secondary"} [props.priority="primary"] priority scale
23
26
  * @param {"small" | "medium" | "large"} [props.size="medium"] size scale
27
+ * @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
24
28
  * @param {boolean} [props.block] block 여부
29
+ * @param {FormFieldWidth} [props.width] container width preset
25
30
  * @param {boolean} [props.isOpen] dropdown open 여부
26
31
  * @param {boolean} [props.disabled] disabled 여부
32
+ * @param {"small" | "medium" | "large"} [props.dropdownSize] dropdown panel size
33
+ * @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
34
+ * @param {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [props.dropdownRootProps] Dropdown.Root 전달 props
35
+ * @param {Omit<DropdownContainerProps, "children" | "size" | "width">} [props.dropdownContainerProps] Dropdown.Container 전달 props
36
+ * @param {DropdownMenuListProps} [props.dropdownMenuListProps] Dropdown.Menu.List 전달 props
37
+ * @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
38
+ * @param {boolean} [props.open] controlled open 상태
39
+ * @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
40
+ * @param {(open: boolean) => void} [props.onOpenChange] open 상태 변경 콜백
27
41
  */
28
42
  const SelectMultipleTrigger = forwardRef<
29
43
  HTMLElement,
@@ -50,6 +64,7 @@ const SelectMultipleTrigger = forwardRef<
50
64
  dropdownRootProps,
51
65
  dropdownContainerProps,
52
66
  dropdownMenuListProps,
67
+ alt,
53
68
  open,
54
69
  defaultOpen,
55
70
  onOpenChange,
@@ -100,10 +115,10 @@ const SelectMultipleTrigger = forwardRef<
100
115
  defaultOpen,
101
116
  onOpenChange,
102
117
  });
103
- // multi select에서도 동일한 open 상태를 유지하기 위해 공통 hook을 사용한다.
118
+ // 변경: outside close는 Radix onOpenChange 기본 동작을 사용한다.
104
119
 
105
120
  const panelSize = (dropdownSize ?? size) as DropdownSize;
106
- const shouldRenderDropdown = options.length > 0;
121
+ const hasOptions = options.length > 0;
107
122
  const MAX_VISIBLE_TAGS = 3;
108
123
  const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
109
124
  const overflowCount = hasTags
@@ -166,36 +181,45 @@ const SelectMultipleTrigger = forwardRef<
166
181
  )}
167
182
  </SelectTriggerBase>
168
183
  </Dropdown.Trigger>
169
- {shouldRenderDropdown ? (
170
- <Dropdown.Container
171
- {...dropdownContainerProps}
172
- size={panelSize}
173
- width={dropdownWidth}
174
- >
175
- <Dropdown.Menu.List {...dropdownMenuListProps}>
176
- {/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
177
- {options.map(option => (
178
- <Dropdown.Menu.Item
179
- key={option.id}
180
- label={option.label}
181
- description={option.description}
182
- disabled={option.disabled}
183
- left={option.left}
184
- right={option.right}
185
- multiple
186
- isSelected={resolvedSelectedIds.includes(option.id)}
187
- onSelect={event => {
188
- if (option.disabled) {
189
- event.preventDefault();
190
- return;
191
- }
192
- onOptionSelect?.(option);
193
- }}
194
- />
195
- ))}
196
- </Dropdown.Menu.List>
197
- </Dropdown.Container>
198
- ) : null}
184
+ <Dropdown.Container
185
+ {...dropdownContainerProps}
186
+ size={panelSize}
187
+ width={dropdownWidth}
188
+ >
189
+ <Dropdown.Menu.List {...dropdownMenuListProps}>
190
+ {hasOptions ? (
191
+ <>
192
+ {/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
193
+ {options.map(option => (
194
+ <Dropdown.Menu.Item
195
+ key={option.id}
196
+ label={option.label}
197
+ description={option.description}
198
+ disabled={option.disabled}
199
+ left={option.left}
200
+ right={option.right}
201
+ multiple
202
+ isSelected={resolvedSelectedIds.includes(option.id)}
203
+ onSelect={event => {
204
+ if (option.disabled) {
205
+ event.preventDefault();
206
+ return;
207
+ }
208
+ onOptionSelect?.(option);
209
+ }}
210
+ />
211
+ ))}
212
+ </>
213
+ ) : (
214
+ <Dropdown.Menu.Item
215
+ // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
216
+ label={alt ?? "선택할 항목이 없습니다."}
217
+ disabled
218
+ className="dropdown-menu-alt"
219
+ />
220
+ )}
221
+ </Dropdown.Menu.List>
222
+ </Dropdown.Container>
199
223
  </Dropdown.Root>
200
224
  </Container>
201
225
  );
@@ -6,13 +6,16 @@ import RemoveIcon from "../../img/remove.svg";
6
6
  import type { SelectMultipleChipProps } from "../../types/multiple";
7
7
 
8
8
  /**
9
- * Select multi chip; 선택된 값을 chip 형태로 표시하고 필요 시 제거 버튼을 노출한다.
9
+ * Select Markup; Multiple 선택값 Chip 렌더링 컴포넌트
10
10
  * @component
11
11
  * @param {SelectMultipleChipProps} props chip props
12
12
  * @param {React.ReactNode} props.label chip 라벨
13
13
  * @param {React.ReactNode} [props.suffix] 라벨 뒤에 붙는 서브 라벨
14
- * @param {boolean} [props.removable] remove 버튼 노출 여부
14
+ * @param {boolean} [props.removable=true] remove 버튼 노출 여부
15
15
  * @param {() => void} [props.onRemove] remove 클릭 핸들러
16
+ * @param {"value" | "summary"} [props.kind="value"] chip 용도 구분
17
+ * @example
18
+ * <SelectMultipleSelectedChip label="Apple" removable onRemove={() => {}} />
16
19
  */
17
20
  export function SelectMultipleSelectedChip({
18
21
  label,
@@ -1,5 +1,6 @@
1
1
  export type * from "./base";
2
2
  export type * from "./icon";
3
+ export type * from "./interaction";
3
4
  export type * from "./props";
4
5
  export type * from "./trigger";
5
6
  export type * from "./multiple";
@@ -0,0 +1,30 @@
1
+ import type { SelectDropdownBehaviorProps } from "./props";
2
+
3
+ /**
4
+ * Select Hook Types; Dropdown open state hook 입력 파라미터
5
+ * @property {boolean} [open] 외부 제어형 open 상태
6
+ * @property {boolean} [defaultOpen] 비제어형 초기 open 상태
7
+ * @property {(open: boolean) => void} [onOpenChange] open 상태 변경 콜백
8
+ */
9
+ export interface UseSelectDropdownOpenStateParams extends SelectDropdownBehaviorProps {}
10
+
11
+ /**
12
+ * Select Hook Types; Dropdown open state hook 반환값
13
+ * @property {boolean} open 최종 open 상태
14
+ * @property {(nextOpen: boolean) => void} setOpen open 상태 업데이트 함수
15
+ * @property {boolean} isControlled open prop 기반 제어형 여부
16
+ */
17
+ export interface UseSelectDropdownOpenStateReturn {
18
+ /**
19
+ * 최종 open 상태
20
+ */
21
+ open: boolean;
22
+ /**
23
+ * open 상태 업데이트 함수
24
+ */
25
+ setOpen: (nextOpen: boolean) => void;
26
+ /**
27
+ * open prop 기반 제어형 여부
28
+ */
29
+ isControlled: boolean;
30
+ }
@@ -142,6 +142,7 @@ export type SelectProps = SelectStyleOptions &
142
142
  * @property {DropdownMenuProps} [dropdownRootProps] Dropdown.Root 전달 props(제어 props 제외)
143
143
  * @property {DropdownContainerProps} [dropdownContainerProps] Dropdown.Container 전달 props(children/size 제외)
144
144
  * @property {DropdownMenuListProps} [dropdownMenuListProps] Dropdown.Menu.List 전달 props
145
+ * @property {ReactNode} [alt] option이 비어 있을 때 렌더링할 alternate 콘텐츠
145
146
  */
146
147
  export interface SelectDropdownConfigProps {
147
148
  /**
@@ -183,6 +184,10 @@ export interface SelectDropdownConfigProps {
183
184
  * Dropdown.Menu.List 전달 props
184
185
  */
185
186
  dropdownMenuListProps?: DropdownMenuListProps;
187
+ /**
188
+ * option이 비어 있을 때 렌더링할 alternate 콘텐츠
189
+ */
190
+ alt?: ReactNode;
186
191
  }
187
192
 
188
193
  /**
@@ -227,6 +232,7 @@ export interface SelectDropdownBehaviorProps {
227
232
  * @property {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [dropdownRootProps] Dropdown.Root 전달 props
228
233
  * @property {Omit<DropdownContainerProps, "children" | "size" | "width">} [dropdownContainerProps] Dropdown.Container 전달 props
229
234
  * @property {DropdownMenuListProps} [dropdownMenuListProps] Dropdown.Menu.List 전달 props
235
+ * @property {ReactNode} [alt] option이 비어 있을 때 렌더링할 alternate 콘텐츠
230
236
  * @property {boolean} [open] dropdown open 상태
231
237
  * @property {boolean} [defaultOpen] uncontrolled 초기 open 상태
232
238
  * @property {(open: boolean) => void} [onOpenChange] open state change 콜백
@@ -257,6 +263,7 @@ export type SelectDefaultComponentProps = SelectTriggerDefaultProps &
257
263
  * @property {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [dropdownRootProps] Dropdown.Root 전달 props
258
264
  * @property {Omit<DropdownContainerProps, "children" | "size" | "width">} [dropdownContainerProps] Dropdown.Container 전달 props
259
265
  * @property {DropdownMenuListProps} [dropdownMenuListProps] Dropdown.Menu.List 전달 props
266
+ * @property {ReactNode} [alt] option이 비어 있을 때 렌더링할 alternate 콘텐츠
260
267
  * @property {boolean} [open] dropdown open 상태
261
268
  * @property {boolean} [defaultOpen] uncontrolled 초기 open 상태
262
269
  * @property {(open: boolean) => void} [onOpenChange] open state change 콜백