@simplysm/solid 13.0.61 → 13.0.64

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.
Files changed (86) hide show
  1. package/README.md +6 -0
  2. package/dist/components/data/crud-detail/CrudDetail.d.ts.map +1 -1
  3. package/dist/components/data/crud-detail/CrudDetail.js +62 -41
  4. package/dist/components/data/crud-detail/CrudDetail.js.map +2 -2
  5. package/dist/components/data/crud-sheet/CrudSheet.d.ts.map +1 -1
  6. package/dist/components/data/crud-sheet/CrudSheet.js +164 -19
  7. package/dist/components/data/crud-sheet/CrudSheet.js.map +2 -2
  8. package/dist/components/data/crud-sheet/types.d.ts +9 -3
  9. package/dist/components/data/crud-sheet/types.d.ts.map +1 -1
  10. package/dist/components/data/sheet/DataSheet.d.ts.map +1 -1
  11. package/dist/components/data/sheet/DataSheet.js +3 -2
  12. package/dist/components/data/sheet/DataSheet.js.map +2 -2
  13. package/dist/components/form-control/checkbox/Checkbox.d.ts.map +1 -1
  14. package/dist/components/form-control/checkbox/Checkbox.js +10 -10
  15. package/dist/components/form-control/checkbox/Checkbox.js.map +2 -2
  16. package/dist/components/form-control/checkbox/Checkbox.styles.d.ts.map +1 -1
  17. package/dist/components/form-control/checkbox/Checkbox.styles.js +2 -2
  18. package/dist/components/form-control/checkbox/Checkbox.styles.js.map +1 -1
  19. package/dist/components/form-control/checkbox/Radio.d.ts.map +1 -1
  20. package/dist/components/form-control/checkbox/Radio.js +13 -13
  21. package/dist/components/form-control/checkbox/Radio.js.map +2 -2
  22. package/dist/components/form-control/data-select-button/DataSelectButton.d.ts +38 -0
  23. package/dist/components/form-control/data-select-button/DataSelectButton.d.ts.map +1 -0
  24. package/dist/components/form-control/data-select-button/DataSelectButton.js +184 -0
  25. package/dist/components/form-control/data-select-button/DataSelectButton.js.map +6 -0
  26. package/dist/components/form-control/select/Select.d.ts +7 -3
  27. package/dist/components/form-control/select/Select.d.ts.map +1 -1
  28. package/dist/components/form-control/select/Select.js +146 -45
  29. package/dist/components/form-control/select/Select.js.map +2 -2
  30. package/dist/components/form-control/select-list/SelectList.d.ts +54 -0
  31. package/dist/components/form-control/select-list/SelectList.d.ts.map +1 -0
  32. package/dist/components/form-control/select-list/SelectList.js +280 -0
  33. package/dist/components/form-control/select-list/SelectList.js.map +6 -0
  34. package/dist/components/form-control/select-list/SelectListContext.d.ts +13 -0
  35. package/dist/components/form-control/select-list/SelectListContext.d.ts.map +1 -0
  36. package/dist/components/form-control/select-list/SelectListContext.js +14 -0
  37. package/dist/components/form-control/select-list/SelectListContext.js.map +6 -0
  38. package/dist/components/form-control/shared-data/SharedDataSelect.d.ts +32 -0
  39. package/dist/components/form-control/shared-data/SharedDataSelect.d.ts.map +1 -0
  40. package/dist/components/form-control/shared-data/SharedDataSelect.js +74 -0
  41. package/dist/components/form-control/shared-data/SharedDataSelect.js.map +6 -0
  42. package/dist/components/form-control/shared-data/SharedDataSelectButton.d.ts +29 -0
  43. package/dist/components/form-control/shared-data/SharedDataSelectButton.d.ts.map +1 -0
  44. package/dist/components/form-control/shared-data/SharedDataSelectButton.js +17 -0
  45. package/dist/components/form-control/shared-data/SharedDataSelectButton.js.map +6 -0
  46. package/dist/components/form-control/shared-data/SharedDataSelectList.d.ts +29 -0
  47. package/dist/components/form-control/shared-data/SharedDataSelectList.d.ts.map +1 -0
  48. package/dist/components/form-control/shared-data/SharedDataSelectList.js +80 -0
  49. package/dist/components/form-control/shared-data/SharedDataSelectList.js.map +6 -0
  50. package/dist/features/address/AddressSearch.d.ts +8 -0
  51. package/dist/features/address/AddressSearch.d.ts.map +1 -0
  52. package/dist/features/address/AddressSearch.js +72 -0
  53. package/dist/features/address/AddressSearch.js.map +6 -0
  54. package/dist/index.d.ts +6 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +6 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/providers/shared-data/SharedDataContext.d.ts +14 -0
  59. package/dist/providers/shared-data/SharedDataContext.d.ts.map +1 -1
  60. package/dist/providers/shared-data/SharedDataContext.js.map +1 -1
  61. package/dist/providers/shared-data/SharedDataProvider.d.ts.map +1 -1
  62. package/dist/providers/shared-data/SharedDataProvider.js +5 -1
  63. package/dist/providers/shared-data/SharedDataProvider.js.map +2 -2
  64. package/docs/data-components.md +15 -4
  65. package/docs/form-controls.md +257 -0
  66. package/docs/hooks.md +30 -0
  67. package/docs/providers.md +7 -0
  68. package/package.json +3 -3
  69. package/src/components/data/crud-detail/CrudDetail.tsx +51 -26
  70. package/src/components/data/crud-sheet/CrudSheet.tsx +157 -20
  71. package/src/components/data/crud-sheet/types.ts +13 -3
  72. package/src/components/data/sheet/DataSheet.tsx +6 -7
  73. package/src/components/form-control/checkbox/Checkbox.styles.ts +2 -2
  74. package/src/components/form-control/checkbox/Checkbox.tsx +18 -20
  75. package/src/components/form-control/checkbox/Radio.tsx +18 -20
  76. package/src/components/form-control/data-select-button/DataSelectButton.tsx +279 -0
  77. package/src/components/form-control/select/Select.tsx +192 -36
  78. package/src/components/form-control/select-list/SelectList.tsx +385 -0
  79. package/src/components/form-control/select-list/SelectListContext.ts +23 -0
  80. package/src/components/form-control/shared-data/SharedDataSelect.tsx +101 -0
  81. package/src/components/form-control/shared-data/SharedDataSelectButton.tsx +47 -0
  82. package/src/components/form-control/shared-data/SharedDataSelectList.tsx +85 -0
  83. package/src/features/address/AddressSearch.tsx +75 -0
  84. package/src/index.ts +18 -0
  85. package/src/providers/shared-data/SharedDataContext.ts +14 -0
  86. package/src/providers/shared-data/SharedDataProvider.tsx +4 -0
@@ -0,0 +1,279 @@
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ createResource,
5
+ createSignal,
6
+ For,
7
+ type JSX,
8
+ on,
9
+ Show,
10
+ splitProps,
11
+ } from "solid-js";
12
+ import clsx from "clsx";
13
+ import { twMerge } from "tailwind-merge";
14
+ import { IconSearch, IconX } from "@tabler/icons-solidjs";
15
+ import { Icon } from "../../display/Icon";
16
+ import { Invalid } from "../Invalid";
17
+ import { useDialog, type DialogShowOptions } from "../../disclosure/DialogContext";
18
+ import { createControllableSignal } from "../../../hooks/createControllableSignal";
19
+ import { type ComponentSize, textMuted } from "../../../styles/tokens.styles";
20
+ import {
21
+ triggerBaseClass,
22
+ triggerDisabledClass,
23
+ triggerInsetClass,
24
+ triggerSizeClasses,
25
+ } from "../DropdownTrigger.styles";
26
+
27
+ /** 모달에서 반환하는 결과 인터페이스 */
28
+ export interface DataSelectModalResult<TKey> {
29
+ selectedKeys: TKey[];
30
+ }
31
+
32
+ /** DataSelectButton Props */
33
+ export interface DataSelectButtonProps<TItem, TKey = string | number> {
34
+ /** 현재 선택된 키 (단일 또는 다중) */
35
+ value?: TKey | TKey[];
36
+ /** 값 변경 콜백 */
37
+ onValueChange?: (value: TKey | TKey[] | undefined) => void;
38
+
39
+ /** 키로 아이템을 로드하는 함수 */
40
+ load: (keys: TKey[]) => TItem[] | Promise<TItem[]>;
41
+ /** 선택 모달 컴포넌트 팩토리 */
42
+ modal: () => JSX.Element;
43
+ /** 아이템 렌더링 함수 */
44
+ renderItem: (item: TItem) => JSX.Element;
45
+
46
+ /** 다중 선택 모드 */
47
+ multiple?: boolean;
48
+ /** 필수 입력 */
49
+ required?: boolean;
50
+ /** 비활성화 */
51
+ disabled?: boolean;
52
+ /** 트리거 크기 */
53
+ size?: ComponentSize;
54
+ /** 테두리 없는 스타일 */
55
+ inset?: boolean;
56
+
57
+ /** 커스텀 유효성 검사 함수 */
58
+ validate?: (value: unknown) => string | undefined;
59
+ /** touchMode: 포커스 해제 후에만 에러 표시 */
60
+ touchMode?: boolean;
61
+
62
+ /** 다이얼로그 옵션 */
63
+ dialogOptions?: DialogShowOptions;
64
+ }
65
+
66
+ // 스타일
67
+ const containerClass = clsx("inline-flex items-center", "group");
68
+ const selectedValueClass = clsx("flex-1", "whitespace-nowrap", "overflow-hidden", "text-ellipsis");
69
+ const actionButtonClass = clsx(
70
+ "flex-shrink-0",
71
+ "p-0.5",
72
+ "rounded",
73
+ "cursor-pointer",
74
+ "transition-colors",
75
+ "hover:bg-base-200 dark:hover:bg-base-700",
76
+ "focus:outline-none",
77
+ );
78
+
79
+ function getTriggerContainerClass(options: {
80
+ size?: ComponentSize;
81
+ disabled?: boolean;
82
+ inset?: boolean;
83
+ class?: string;
84
+ }): string {
85
+ return twMerge(
86
+ triggerBaseClass,
87
+ "px-2 py-1",
88
+ options.size && triggerSizeClasses[options.size],
89
+ options.disabled && triggerDisabledClass,
90
+ options.inset && triggerInsetClass,
91
+ options.class,
92
+ );
93
+ }
94
+
95
+ export function DataSelectButton<TItem, TKey = string | number>(
96
+ props: DataSelectButtonProps<TItem, TKey>,
97
+ ): JSX.Element {
98
+ const [local] = splitProps(props, [
99
+ "value",
100
+ "onValueChange",
101
+ "load",
102
+ "modal",
103
+ "renderItem",
104
+ "multiple",
105
+ "required",
106
+ "disabled",
107
+ "size",
108
+ "inset",
109
+ "validate",
110
+ "touchMode",
111
+ "dialogOptions",
112
+ ]);
113
+
114
+ const dialog = useDialog();
115
+
116
+ // value를 항상 배열로 정규화
117
+ const normalizeKeys = (value: TKey | TKey[] | undefined): TKey[] => {
118
+ if (value === undefined || value === null) return [];
119
+ if (Array.isArray(value)) return value;
120
+ return [value];
121
+ };
122
+
123
+ // controlled/uncontrolled 패턴
124
+ type ValueType = TKey | TKey[] | undefined;
125
+ const [getValue, setValue] = createControllableSignal<ValueType>({
126
+ value: () => local.value,
127
+ onChange: () => local.onValueChange as ((v: ValueType) => void) | undefined,
128
+ } as Parameters<typeof createControllableSignal<ValueType>>[0]);
129
+
130
+ // load를 위한 키 추적 signal
131
+ // eslint-disable-next-line solid/reactivity -- 초기값은 mount 시점에 한 번만 읽음
132
+ const [loadKeys, setLoadKeys] = createSignal<TKey[]>(normalizeKeys(local.value));
133
+
134
+ // value가 변경되면 loadKeys 업데이트
135
+ createEffect(
136
+ on(
137
+ () => getValue(),
138
+ (value) => {
139
+ setLoadKeys(normalizeKeys(value));
140
+ },
141
+ ),
142
+ );
143
+
144
+ // createResource로 load 호출
145
+ // eslint-disable-next-line solid/reactivity -- createResource의 fetcher는 source 변경 시 호출됨
146
+ const [selectedItems] = createResource(loadKeys, async (keys) => {
147
+ if (keys.length === 0) return [];
148
+ return Promise.resolve(local.load(keys));
149
+ });
150
+
151
+ // 값이 있는지 확인
152
+ const hasValue = createMemo(() => {
153
+ const keys = normalizeKeys(getValue());
154
+ return keys.length > 0;
155
+ });
156
+
157
+ // 지우기 가능 여부
158
+ const clearable = createMemo(() => !local.required && hasValue() && !local.disabled);
159
+
160
+ // 유효성 검사
161
+ const errorMsg = createMemo(() => {
162
+ const v = getValue();
163
+ if (local.required) {
164
+ const keys = normalizeKeys(v);
165
+ if (keys.length === 0) return "필수 입력 항목입니다";
166
+ }
167
+ return local.validate?.(v);
168
+ });
169
+
170
+ // 모달 열기
171
+ const handleOpenModal = async () => {
172
+ if (local.disabled) return;
173
+
174
+ const result = await dialog.show<DataSelectModalResult<TKey>>(
175
+ local.modal,
176
+ local.dialogOptions ?? {},
177
+ );
178
+
179
+ if (result) {
180
+ const newKeys = result.selectedKeys;
181
+ if (local.multiple) {
182
+ setValue(newKeys);
183
+ } else {
184
+ setValue(newKeys.length > 0 ? newKeys[0] : undefined);
185
+ }
186
+ }
187
+ };
188
+
189
+ // 지우기
190
+ const handleClear = (e: MouseEvent) => {
191
+ e.stopPropagation();
192
+ if (local.multiple) {
193
+ setValue([] as unknown as TKey[]);
194
+ } else {
195
+ setValue(undefined);
196
+ }
197
+ };
198
+
199
+ // 선택된 값 표시
200
+ const renderSelectedDisplay = (): JSX.Element => {
201
+ const items = selectedItems();
202
+ if (!items || items.length === 0) {
203
+ return <span class={textMuted} />;
204
+ }
205
+ return (
206
+ <span class="flex items-center gap-1">
207
+ <For each={items}>
208
+ {(item, index) => (
209
+ <>
210
+ <Show when={index() > 0}>
211
+ <span class={textMuted}>,</span>
212
+ </Show>
213
+ {local.renderItem(item)}
214
+ </>
215
+ )}
216
+ </For>
217
+ </span>
218
+ );
219
+ };
220
+
221
+ // 트리거 클래스 계산
222
+ const triggerClassName = () =>
223
+ getTriggerContainerClass({
224
+ size: local.size,
225
+ disabled: local.disabled,
226
+ inset: local.inset,
227
+ });
228
+
229
+ return (
230
+ <Invalid message={errorMsg()} variant="border" touchMode={local.touchMode}>
231
+ <div data-data-select-button class={containerClass}>
232
+ <div
233
+ role="combobox"
234
+ aria-haspopup="dialog"
235
+ aria-expanded={false}
236
+ aria-disabled={local.disabled || undefined}
237
+ aria-required={local.required || undefined}
238
+ tabIndex={local.disabled ? -1 : 0}
239
+ class={triggerClassName()}
240
+ onKeyDown={(e) => {
241
+ if (local.disabled) return;
242
+ if (e.key === "Enter" || e.key === " ") {
243
+ e.preventDefault();
244
+ void handleOpenModal();
245
+ }
246
+ }}
247
+ >
248
+ <div class={selectedValueClass}>{renderSelectedDisplay()}</div>
249
+ <div class="flex items-center gap-0.5">
250
+ <Show when={clearable()}>
251
+ <button
252
+ type="button"
253
+ data-clear-button
254
+ class={twMerge(actionButtonClass, "text-base-400 hover:text-danger-500")}
255
+ onClick={handleClear}
256
+ tabIndex={-1}
257
+ aria-label="선택 해제"
258
+ >
259
+ <Icon icon={IconX} size="0.875em" />
260
+ </button>
261
+ </Show>
262
+ <Show when={!local.disabled}>
263
+ <button
264
+ type="button"
265
+ data-search-button
266
+ class={twMerge(actionButtonClass, "text-base-400 hover:text-primary-500")}
267
+ onClick={() => void handleOpenModal()}
268
+ tabIndex={-1}
269
+ aria-label="검색"
270
+ >
271
+ <Icon icon={IconSearch} size="0.875em" />
272
+ </button>
273
+ </Show>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </Invalid>
278
+ );
279
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  children,
3
+ createEffect,
3
4
  createMemo,
4
5
  createSignal,
5
6
  For,
@@ -19,7 +20,13 @@ import { SelectContext, type SelectContextValue } from "./SelectContext";
19
20
  import { useSelectContext } from "./SelectContext";
20
21
  import { SelectItem } from "./SelectItem";
21
22
  import { ripple } from "../../../directives/ripple";
22
- import { borderDefault, type ComponentSize, textMuted } from "../../../styles/tokens.styles";
23
+ import {
24
+ borderDefault,
25
+ borderSubtle,
26
+ type ComponentSize,
27
+ textMuted,
28
+ textPlaceholder,
29
+ } from "../../../styles/tokens.styles";
23
30
  import { createControllableSignal } from "../../../hooks/createControllableSignal";
24
31
  import { createSlotSignal } from "../../../hooks/createSlotSignal";
25
32
  import { chevronWrapperClass, getTriggerClass } from "../DropdownTrigger.styles";
@@ -31,6 +38,28 @@ void ripple;
31
38
  const multiTagClass = clsx("rounded", "bg-base-200 px-1", "dark:bg-base-600");
32
39
  const selectedValueClass = clsx("flex-1", "whitespace-nowrap");
33
40
 
41
+ // 검색 입력 스타일
42
+ const searchInputClass = clsx(
43
+ "w-full",
44
+ "border-b",
45
+ borderSubtle,
46
+ "bg-transparent",
47
+ "px-2 py-1.5",
48
+ "text-sm",
49
+ "outline-none",
50
+ textPlaceholder,
51
+ );
52
+
53
+ // 전체선택/해제 버튼 영역 스타일
54
+ const selectAllBarClass = clsx("flex gap-2", "border-b", borderSubtle, "px-2 py-1", "text-xs");
55
+
56
+ // 전체선택/해제 버튼 스타일
57
+ const selectAllBtnClass = clsx(
58
+ "text-primary-500",
59
+ "hover:text-primary-600 dark:hover:text-primary-400",
60
+ "cursor-pointer",
61
+ );
62
+
34
63
  /**
35
64
  * Select 우측 액션 서브 컴포넌트
36
65
  */
@@ -94,7 +123,7 @@ const SelectItemTemplate = <TArgs extends unknown[]>(props: {
94
123
  // Props 정의
95
124
 
96
125
  // 공통 Props (value, onValueChange, multiple 제외)
97
- interface SelectCommonProps {
126
+ interface SelectCommonProps<TValue = unknown> {
98
127
  /** 비활성화 */
99
128
  disabled?: boolean;
100
129
 
@@ -116,6 +145,12 @@ interface SelectCommonProps {
116
145
  /** touchMode: 포커스 해제 후에만 에러 표시 */
117
146
  touchMode?: boolean;
118
147
 
148
+ /** 검색 텍스트 추출 함수 (설정 시 검색 입력 자동 표시) */
149
+ getSearchText?: (item: TValue) => string;
150
+
151
+ /** 숨김 여부 판별 함수 */
152
+ getIsHidden?: (item: TValue) => boolean;
153
+
119
154
  /** 커스텀 class */
120
155
  class?: string;
121
156
 
@@ -124,7 +159,7 @@ interface SelectCommonProps {
124
159
  }
125
160
 
126
161
  // 단일 선택 Props
127
- interface SelectSingleBaseProps<TValue> extends SelectCommonProps {
162
+ interface SelectSingleBaseProps<TValue> extends SelectCommonProps<TValue> {
128
163
  /** 다중 선택 모드 */
129
164
  multiple?: false;
130
165
 
@@ -142,7 +177,7 @@ interface SelectSingleBaseProps<TValue> extends SelectCommonProps {
142
177
  }
143
178
 
144
179
  // 다중 선택 Props
145
- interface SelectMultipleBaseProps<TValue> extends SelectCommonProps {
180
+ interface SelectMultipleBaseProps<TValue> extends SelectCommonProps<TValue> {
146
181
  /** 다중 선택 모드 */
147
182
  multiple: true;
148
183
 
@@ -228,40 +263,52 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
228
263
  "renderValue",
229
264
  "validate",
230
265
  "touchMode",
266
+ "getSearchText",
267
+ "getIsHidden",
231
268
  ]);
232
269
 
233
270
  const [open, setOpen] = createSignal(false);
234
271
 
272
+ // 검색 텍스트 signal
273
+ const [searchText, setSearchText] = createSignal("");
274
+
275
+ // open → false 시 searchText 초기화
276
+ createEffect(() => {
277
+ if (!open()) {
278
+ setSearchText("");
279
+ }
280
+ });
281
+
235
282
  // 선택된 값 관리 (controlled/uncontrolled 패턴)
236
283
  type ValueType = T | T[] | undefined;
237
- const [getValue, setInternalValue] = createControllableSignal<ValueType>({
284
+ const [value, setValue] = createControllableSignal<ValueType>({
238
285
  value: () => local.value,
239
286
  onChange: () => local.onValueChange as ((v: ValueType) => void) | undefined,
240
287
  } as Parameters<typeof createControllableSignal<ValueType>>[0]);
241
288
 
242
289
  // 값이 선택되어 있는지 확인
243
- const isSelected = (value: T): boolean => {
244
- const current = getValue();
290
+ const isSelected = (itemValue: T): boolean => {
291
+ const current = value();
245
292
  if (current === undefined) return false;
246
293
 
247
294
  if (local.multiple) {
248
- return Array.isArray(current) && current.includes(value);
295
+ return Array.isArray(current) && current.includes(itemValue);
249
296
  }
250
- return current === value;
297
+ return current === itemValue;
251
298
  };
252
299
 
253
300
  // 값 토글
254
- const toggleValue = (value: T) => {
301
+ const toggleValue = (itemValue: T) => {
255
302
  if (local.multiple) {
256
- const current = (getValue() as T[] | undefined) ?? [];
257
- const idx = current.indexOf(value);
303
+ const current = (value() as T[] | undefined) ?? [];
304
+ const idx = current.indexOf(itemValue);
258
305
  if (idx >= 0) {
259
- setInternalValue([...current.slice(0, idx), ...current.slice(idx + 1)] as T[]);
306
+ setValue([...current.slice(0, idx), ...current.slice(idx + 1)] as T[]);
260
307
  } else {
261
- setInternalValue([...current, value] as T[]);
308
+ setValue([...current, itemValue] as T[]);
262
309
  }
263
310
  } else {
264
- setInternalValue(value);
311
+ setValue(itemValue);
265
312
  }
266
313
  };
267
314
 
@@ -302,7 +349,7 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
302
349
 
303
350
  // 유효성 검사 메시지
304
351
  const errorMsg = createMemo(() => {
305
- const v = getValue();
352
+ const v = value();
306
353
  if (local.required && (v === undefined || v === null || v === ""))
307
354
  return "필수 입력 항목입니다";
308
355
  return local.validate?.(v);
@@ -317,6 +364,57 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
317
364
  class: local.class,
318
365
  });
319
366
 
367
+ // 검색 필터링 (계층 구조 지원)
368
+ const filteredItems = createMemo((): T[] | undefined => {
369
+ if (!local.items) return undefined;
370
+ if (!local.getSearchText || !searchText()) return local.items;
371
+
372
+ const terms = searchText().trim().split(" ").filter(Boolean);
373
+ if (terms.length === 0) return local.items;
374
+
375
+ // 계층 구조에서 자식 매칭 시 부모도 포함
376
+ const matchesSearch = (item: T): boolean => {
377
+ const text = local.getSearchText!(item).toLowerCase();
378
+ if (terms.every((t) => text.includes(t.toLowerCase()))) return true;
379
+
380
+ // 자식 중 매칭되는 항목이 있으면 부모도 표시
381
+ if (local.getChildren) {
382
+ const itemChildren = local.getChildren(item, 0, 0);
383
+ if (itemChildren?.some((child) => matchesSearch(child))) return true;
384
+ }
385
+
386
+ return false;
387
+ };
388
+
389
+ return local.items.filter((item) => matchesSearch(item));
390
+ });
391
+
392
+ // 숨김 필터링 적용된 items
393
+ const visibleItems = createMemo((): T[] | undefined => {
394
+ const items = filteredItems();
395
+ if (!items || !local.getIsHidden) return items;
396
+
397
+ return items.filter((item) => {
398
+ // 숨김 항목이지만 선택된 경우 표시 (취소선으로)
399
+ if (local.getIsHidden!(item)) {
400
+ return isSelected(item);
401
+ }
402
+ return true;
403
+ });
404
+ });
405
+
406
+ // 전체선택
407
+ const handleSelectAll = () => {
408
+ const items = visibleItems();
409
+ if (!items) return;
410
+ setValue(items);
411
+ };
412
+
413
+ // 전체해제
414
+ const handleDeselectAll = () => {
415
+ setValue([] as unknown as T[]);
416
+ };
417
+
320
418
  // 내부 컴포넌트: Provider 안에서 children을 resolve하여 슬롯 등록을 트리거
321
419
  const SelectInner: ParentComponent = (innerProps) => {
322
420
  // children() resolve로 서브 컴포넌트 등록 트리거 (Header, Action, ItemTemplate은 null 반환)
@@ -334,39 +432,52 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
334
432
  const tpl = getItemTemplate();
335
433
  return (
336
434
  <For each={itemList}>
337
- {(item, index) => (
338
- <SelectItem value={item}>
339
- {tpl ? tpl(item, index(), depth) : String(item)}
340
- <Show when={local.getChildren?.(item, index(), depth)} keyed>
341
- {(itemChildren) => (
342
- <Show when={itemChildren.length > 0}>
343
- <SelectItem.Children>
344
- {renderItems(itemChildren, depth + 1)}
345
- </SelectItem.Children>
346
- </Show>
347
- )}
348
- </Show>
349
- </SelectItem>
350
- )}
435
+ {(item, index) => {
436
+ const hidden = () => local.getIsHidden?.(item) ?? false;
437
+ return (
438
+ <SelectItem value={item} class={hidden() ? "line-through opacity-60" : undefined}>
439
+ {tpl ? tpl(item, index(), depth) : String(item)}
440
+ <Show when={local.getChildren?.(item, index(), depth)} keyed>
441
+ {(itemChildren) => {
442
+ // 자식 목록에서 숨김 필터링 적용
443
+ const visibleChildren = () => {
444
+ if (!local.getIsHidden) return itemChildren;
445
+ return itemChildren.filter((child) => {
446
+ if (local.getIsHidden!(child)) return isSelected(child);
447
+ return true;
448
+ });
449
+ };
450
+ return (
451
+ <Show when={visibleChildren().length > 0}>
452
+ <SelectItem.Children>
453
+ {renderItems(visibleChildren(), depth + 1)}
454
+ </SelectItem.Children>
455
+ </Show>
456
+ );
457
+ }}
458
+ </Show>
459
+ </SelectItem>
460
+ );
461
+ }}
351
462
  </For>
352
463
  );
353
464
  };
354
465
 
355
466
  // 선택된 값 렌더링 (items 방식일 때 itemTemplate 재사용)
356
- const renderValue = (value: T): JSX.Element => {
467
+ const renderValue = (renderVal: T): JSX.Element => {
357
468
  if (local.renderValue) {
358
- return local.renderValue(value);
469
+ return local.renderValue(renderVal);
359
470
  }
360
471
  const tpl = getItemTemplate();
361
472
  if (tpl) {
362
- return tpl(value, 0, 0);
473
+ return tpl(renderVal, 0, 0);
363
474
  }
364
- return <>{String(value)}</>;
475
+ return <>{String(renderVal)}</>;
365
476
  };
366
477
 
367
478
  // 선택된 값 표시
368
479
  const renderSelectedValue = (): JSX.Element => {
369
- const current = getValue();
480
+ const current = value();
370
481
 
371
482
  if (current === undefined || (Array.isArray(current) && current.length === 0)) {
372
483
  return <span class={textMuted}>{local.placeholder ?? ""}</span>;
@@ -384,6 +495,13 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
384
495
  return renderValue(current as T);
385
496
  };
386
497
 
498
+ // 미지정 항목 표시 여부: 단일 선택 + required 아님 + items 모드
499
+ const showUnsetItem = () => !local.multiple && !local.required && local.items !== undefined;
500
+
501
+ // 전체선택/해제 버튼 표시 여부: multiple + hideSelectAll 아님 + items 모드
502
+ const showSelectAllBar = () =>
503
+ local.multiple === true && !local.hideSelectAll && local.items !== undefined;
504
+
387
505
  return (
388
506
  <div {...rest} data-select class={clsx("group", local.inset ? "flex" : "inline-flex")}>
389
507
  <Dropdown disabled={local.disabled} open={open()} onOpenChange={setOpen} keyboardNav>
@@ -415,9 +533,47 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
415
533
  </Dropdown.Trigger>
416
534
  <Dropdown.Content>
417
535
  <Show when={header()}>{header()!()}</Show>
536
+ {/* 검색 입력 */}
537
+ <Show when={local.getSearchText && local.items}>
538
+ <input
539
+ type="text"
540
+ data-select-search
541
+ class={searchInputClass}
542
+ placeholder="검색..."
543
+ value={searchText()}
544
+ onInput={(e) => setSearchText(e.currentTarget.value)}
545
+ />
546
+ </Show>
547
+ {/* 전체선택/해제 버튼 */}
548
+ <Show when={showSelectAllBar()}>
549
+ <div class={selectAllBarClass}>
550
+ <button
551
+ type="button"
552
+ data-select-all
553
+ class={selectAllBtnClass}
554
+ onClick={handleSelectAll}
555
+ >
556
+ 전체선택
557
+ </button>
558
+ <button
559
+ type="button"
560
+ data-deselect-all
561
+ class={selectAllBtnClass}
562
+ onClick={handleDeselectAll}
563
+ >
564
+ 전체해제
565
+ </button>
566
+ </div>
567
+ </Show>
418
568
  <List inset role="listbox">
419
569
  <Show when={local.items} fallback={resolved()}>
420
- {renderItems(local.items!, 0)}
570
+ {/* 미지정 항목 */}
571
+ <Show when={showUnsetItem()}>
572
+ <SelectItem value={undefined as T}>
573
+ <span class={textMuted}>미지정</span>
574
+ </SelectItem>
575
+ </Show>
576
+ {renderItems(visibleItems() ?? [], 0)}
421
577
  </Show>
422
578
  </List>
423
579
  </Dropdown.Content>