@simplysm/solid 13.0.62 → 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.
- package/README.md +6 -0
- package/dist/components/data/crud-detail/CrudDetail.d.ts.map +1 -1
- package/dist/components/data/crud-detail/CrudDetail.js +62 -41
- package/dist/components/data/crud-detail/CrudDetail.js.map +2 -2
- package/dist/components/data/crud-sheet/CrudSheet.d.ts.map +1 -1
- package/dist/components/data/crud-sheet/CrudSheet.js +164 -19
- package/dist/components/data/crud-sheet/CrudSheet.js.map +2 -2
- package/dist/components/data/crud-sheet/types.d.ts +9 -3
- package/dist/components/data/crud-sheet/types.d.ts.map +1 -1
- package/dist/components/data/sheet/DataSheet.d.ts.map +1 -1
- package/dist/components/data/sheet/DataSheet.js +3 -2
- package/dist/components/data/sheet/DataSheet.js.map +2 -2
- package/dist/components/form-control/checkbox/Checkbox.d.ts.map +1 -1
- package/dist/components/form-control/checkbox/Checkbox.js +10 -10
- package/dist/components/form-control/checkbox/Checkbox.js.map +2 -2
- package/dist/components/form-control/checkbox/Checkbox.styles.d.ts.map +1 -1
- package/dist/components/form-control/checkbox/Checkbox.styles.js +2 -2
- package/dist/components/form-control/checkbox/Checkbox.styles.js.map +1 -1
- package/dist/components/form-control/checkbox/Radio.d.ts.map +1 -1
- package/dist/components/form-control/checkbox/Radio.js +13 -13
- package/dist/components/form-control/checkbox/Radio.js.map +2 -2
- package/dist/components/form-control/data-select-button/DataSelectButton.d.ts +38 -0
- package/dist/components/form-control/data-select-button/DataSelectButton.d.ts.map +1 -0
- package/dist/components/form-control/data-select-button/DataSelectButton.js +184 -0
- package/dist/components/form-control/data-select-button/DataSelectButton.js.map +6 -0
- package/dist/components/form-control/select/Select.d.ts +7 -3
- package/dist/components/form-control/select/Select.d.ts.map +1 -1
- package/dist/components/form-control/select/Select.js +146 -45
- package/dist/components/form-control/select/Select.js.map +2 -2
- package/dist/components/form-control/select-list/SelectList.d.ts +54 -0
- package/dist/components/form-control/select-list/SelectList.d.ts.map +1 -0
- package/dist/components/form-control/select-list/SelectList.js +280 -0
- package/dist/components/form-control/select-list/SelectList.js.map +6 -0
- package/dist/components/form-control/select-list/SelectListContext.d.ts +13 -0
- package/dist/components/form-control/select-list/SelectListContext.d.ts.map +1 -0
- package/dist/components/form-control/select-list/SelectListContext.js +14 -0
- package/dist/components/form-control/select-list/SelectListContext.js.map +6 -0
- package/dist/components/form-control/shared-data/SharedDataSelect.d.ts +32 -0
- package/dist/components/form-control/shared-data/SharedDataSelect.d.ts.map +1 -0
- package/dist/components/form-control/shared-data/SharedDataSelect.js +74 -0
- package/dist/components/form-control/shared-data/SharedDataSelect.js.map +6 -0
- package/dist/components/form-control/shared-data/SharedDataSelectButton.d.ts +29 -0
- package/dist/components/form-control/shared-data/SharedDataSelectButton.d.ts.map +1 -0
- package/dist/components/form-control/shared-data/SharedDataSelectButton.js +17 -0
- package/dist/components/form-control/shared-data/SharedDataSelectButton.js.map +6 -0
- package/dist/components/form-control/shared-data/SharedDataSelectList.d.ts +29 -0
- package/dist/components/form-control/shared-data/SharedDataSelectList.d.ts.map +1 -0
- package/dist/components/form-control/shared-data/SharedDataSelectList.js +80 -0
- package/dist/components/form-control/shared-data/SharedDataSelectList.js.map +6 -0
- package/dist/features/address/AddressSearch.d.ts +8 -0
- package/dist/features/address/AddressSearch.d.ts.map +1 -0
- package/dist/features/address/AddressSearch.js +72 -0
- package/dist/features/address/AddressSearch.js.map +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/shared-data/SharedDataContext.d.ts +14 -0
- package/dist/providers/shared-data/SharedDataContext.d.ts.map +1 -1
- package/dist/providers/shared-data/SharedDataContext.js.map +1 -1
- package/dist/providers/shared-data/SharedDataProvider.d.ts.map +1 -1
- package/dist/providers/shared-data/SharedDataProvider.js +5 -1
- package/dist/providers/shared-data/SharedDataProvider.js.map +2 -2
- package/docs/data-components.md +15 -4
- package/docs/form-controls.md +257 -0
- package/docs/hooks.md +30 -0
- package/docs/providers.md +7 -0
- package/package.json +3 -3
- package/src/components/data/crud-detail/CrudDetail.tsx +51 -26
- package/src/components/data/crud-sheet/CrudSheet.tsx +157 -20
- package/src/components/data/crud-sheet/types.ts +13 -3
- package/src/components/data/sheet/DataSheet.tsx +6 -7
- package/src/components/form-control/checkbox/Checkbox.styles.ts +2 -2
- package/src/components/form-control/checkbox/Checkbox.tsx +18 -20
- package/src/components/form-control/checkbox/Radio.tsx +18 -20
- package/src/components/form-control/data-select-button/DataSelectButton.tsx +279 -0
- package/src/components/form-control/select/Select.tsx +192 -36
- package/src/components/form-control/select-list/SelectList.tsx +385 -0
- package/src/components/form-control/select-list/SelectListContext.ts +23 -0
- package/src/components/form-control/shared-data/SharedDataSelect.tsx +101 -0
- package/src/components/form-control/shared-data/SharedDataSelectButton.tsx +47 -0
- package/src/components/form-control/shared-data/SharedDataSelectList.tsx +85 -0
- package/src/features/address/AddressSearch.tsx +75 -0
- package/src/index.ts +18 -0
- package/src/providers/shared-data/SharedDataContext.ts +14 -0
- package/src/providers/shared-data/SharedDataProvider.tsx +4 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
children,
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createSignal,
|
|
6
|
+
For,
|
|
7
|
+
type JSX,
|
|
8
|
+
onCleanup,
|
|
9
|
+
type ParentComponent,
|
|
10
|
+
Show,
|
|
11
|
+
splitProps,
|
|
12
|
+
} from "solid-js";
|
|
13
|
+
import clsx from "clsx";
|
|
14
|
+
import { twMerge } from "tailwind-merge";
|
|
15
|
+
import { List } from "../../data/list/List";
|
|
16
|
+
import { Pagination } from "../../data/Pagination";
|
|
17
|
+
import { TextInput } from "../field/TextInput";
|
|
18
|
+
import { createSlotSignal } from "../../../hooks/createSlotSignal";
|
|
19
|
+
import { SelectListContext, type SelectListContextValue } from "./SelectListContext";
|
|
20
|
+
import { useSelectListContext } from "./SelectListContext";
|
|
21
|
+
import {
|
|
22
|
+
listItemBaseClass,
|
|
23
|
+
listItemSelectedClass,
|
|
24
|
+
listItemContentClass,
|
|
25
|
+
listItemDisabledClass,
|
|
26
|
+
} from "../../data/list/ListItem.styles";
|
|
27
|
+
import { ripple } from "../../../directives/ripple";
|
|
28
|
+
import { textMuted } from "../../../styles/tokens.styles";
|
|
29
|
+
|
|
30
|
+
void ripple;
|
|
31
|
+
|
|
32
|
+
// ─── 서브 컴포넌트 ──────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Header 슬롯 서브 컴포넌트
|
|
36
|
+
*/
|
|
37
|
+
const SelectListHeader: ParentComponent = (props) => {
|
|
38
|
+
const ctx = useSelectListContext();
|
|
39
|
+
// eslint-disable-next-line solid/reactivity -- 슬롯 accessor로 저장, JSX tracked scope에서 호출됨
|
|
40
|
+
ctx.setHeader(() => props.children);
|
|
41
|
+
onCleanup(() => ctx.setHeader(undefined));
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Filter 슬롯 서브 컴포넌트
|
|
47
|
+
*/
|
|
48
|
+
const SelectListFilter: ParentComponent = (props) => {
|
|
49
|
+
const ctx = useSelectListContext();
|
|
50
|
+
// eslint-disable-next-line solid/reactivity -- 슬롯 accessor로 저장, JSX tracked scope에서 호출됨
|
|
51
|
+
ctx.setFilter(() => props.children);
|
|
52
|
+
onCleanup(() => ctx.setFilter(undefined));
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ItemTemplate 서브 컴포넌트
|
|
58
|
+
*/
|
|
59
|
+
const SelectListItemTemplate = <TArgs extends unknown[]>(props: {
|
|
60
|
+
children: (...args: TArgs) => JSX.Element;
|
|
61
|
+
}) => {
|
|
62
|
+
const ctx = useSelectListContext();
|
|
63
|
+
// eslint-disable-next-line solid/reactivity -- 렌더 함수를 signal에 저장, JSX tracked scope에서 호출됨
|
|
64
|
+
ctx.setItemTemplate(props.children as (...args: unknown[]) => JSX.Element);
|
|
65
|
+
onCleanup(() => ctx.setItemTemplate(undefined));
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ─── Props ──────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface SelectListProps<TValue> {
|
|
72
|
+
/** 목록 아이템 배열 */
|
|
73
|
+
items: TValue[];
|
|
74
|
+
|
|
75
|
+
/** 현재 선택된 값 */
|
|
76
|
+
value?: TValue;
|
|
77
|
+
|
|
78
|
+
/** 값 변경 콜백 */
|
|
79
|
+
onValueChange?: (value: TValue | undefined) => void;
|
|
80
|
+
|
|
81
|
+
/** 필수 선택 여부 (true이면 미지정 항목 숨김) */
|
|
82
|
+
required?: boolean;
|
|
83
|
+
|
|
84
|
+
/** 비활성화 */
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
|
|
87
|
+
/** 검색 텍스트 추출 함수 (있으면 검색 TextInput 자동 표시) */
|
|
88
|
+
getSearchText?: (item: TValue) => string;
|
|
89
|
+
|
|
90
|
+
/** 숨김 여부 필터 */
|
|
91
|
+
getIsHidden?: (item: TValue) => boolean;
|
|
92
|
+
|
|
93
|
+
/** 커스텀 필터 함수 */
|
|
94
|
+
filterFn?: (item: TValue, index: number) => boolean;
|
|
95
|
+
|
|
96
|
+
/** 값 변경 가드 (false 반환 시 변경 차단) */
|
|
97
|
+
canChange?: (item: TValue | undefined) => boolean | Promise<boolean>;
|
|
98
|
+
|
|
99
|
+
/** 페이지 크기 (있으면 Pagination 자동 표시) */
|
|
100
|
+
pageSize?: number;
|
|
101
|
+
|
|
102
|
+
/** 헤더 텍스트 (Header 슬롯보다 우선순위 낮음) */
|
|
103
|
+
header?: string;
|
|
104
|
+
|
|
105
|
+
/** 커스텀 class */
|
|
106
|
+
class?: string;
|
|
107
|
+
|
|
108
|
+
/** 커스텀 style */
|
|
109
|
+
style?: JSX.CSSProperties;
|
|
110
|
+
|
|
111
|
+
/** 서브 컴포넌트용 children */
|
|
112
|
+
children?: JSX.Element;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── 스타일 ──────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const containerClass = clsx("inline-flex flex-col gap-1");
|
|
118
|
+
|
|
119
|
+
const headerClass = clsx("px-2 py-1 text-sm font-semibold");
|
|
120
|
+
|
|
121
|
+
// ─── 컴포넌트 ───────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface SelectListComponent {
|
|
124
|
+
<TValue = unknown>(props: SelectListProps<TValue>): JSX.Element;
|
|
125
|
+
Header: typeof SelectListHeader;
|
|
126
|
+
Filter: typeof SelectListFilter;
|
|
127
|
+
ItemTemplate: typeof SelectListItemTemplate;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const SelectList: SelectListComponent = <TValue,>(props: SelectListProps<TValue>) => {
|
|
131
|
+
const [local, rest] = splitProps(props as SelectListProps<TValue> & { children?: JSX.Element }, [
|
|
132
|
+
"children",
|
|
133
|
+
"class",
|
|
134
|
+
"style",
|
|
135
|
+
"items",
|
|
136
|
+
"value",
|
|
137
|
+
"onValueChange",
|
|
138
|
+
"required",
|
|
139
|
+
"disabled",
|
|
140
|
+
"getSearchText",
|
|
141
|
+
"getIsHidden",
|
|
142
|
+
"filterFn",
|
|
143
|
+
"canChange",
|
|
144
|
+
"pageSize",
|
|
145
|
+
"header",
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
// ─── 내부 상태 ─────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const [searchText, setSearchText] = createSignal("");
|
|
151
|
+
const [page, setPage] = createSignal(1);
|
|
152
|
+
|
|
153
|
+
// ─── 슬롯 signals ────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const [headerSlot, setHeader] = createSlotSignal();
|
|
156
|
+
const [filterSlot, setFilter] = createSlotSignal();
|
|
157
|
+
const [itemTemplate, _setItemTemplate] = createSignal<
|
|
158
|
+
((...args: unknown[]) => JSX.Element) | undefined
|
|
159
|
+
>();
|
|
160
|
+
const setItemTemplate = (fn: ((...args: unknown[]) => JSX.Element) | undefined) =>
|
|
161
|
+
_setItemTemplate(() => fn);
|
|
162
|
+
|
|
163
|
+
// Context 값
|
|
164
|
+
const contextValue: SelectListContextValue = {
|
|
165
|
+
setHeader,
|
|
166
|
+
setFilter,
|
|
167
|
+
setItemTemplate,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ─── items 변경 시 value 자동 재매칭 ──────────────────
|
|
171
|
+
|
|
172
|
+
createEffect(() => {
|
|
173
|
+
const currentItems = local.items;
|
|
174
|
+
const currentValue = local.value;
|
|
175
|
+
if (currentValue === undefined) return;
|
|
176
|
+
|
|
177
|
+
// 새 items에서 현재 value를 참조로 찾기
|
|
178
|
+
const found = currentItems.find((item) => item === currentValue);
|
|
179
|
+
if (found !== undefined) {
|
|
180
|
+
// 이미 같은 참조면 아무 것도 하지 않음
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 참조가 없으면 현재 value를 유지 (호출하지 않음)
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ─── 필터링 파이프라인 ─────────────────────────────────
|
|
188
|
+
|
|
189
|
+
// getIsHidden 필터 → 검색 필터 → filterFn
|
|
190
|
+
const filteredItems = createMemo(() => {
|
|
191
|
+
let result = local.items;
|
|
192
|
+
|
|
193
|
+
// getIsHidden 필터
|
|
194
|
+
if (local.getIsHidden) {
|
|
195
|
+
const fn = local.getIsHidden;
|
|
196
|
+
result = result.filter((item) => !fn(item));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 검색 필터
|
|
200
|
+
const search = searchText().trim().toLowerCase();
|
|
201
|
+
if (search && local.getSearchText) {
|
|
202
|
+
const getText = local.getSearchText;
|
|
203
|
+
result = result.filter((item) => getText(item).toLowerCase().includes(search));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// filterFn
|
|
207
|
+
if (local.filterFn) {
|
|
208
|
+
const fn = local.filterFn;
|
|
209
|
+
result = result.filter((item, index) => fn(item, index));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return result;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// 페이지 수 계산
|
|
216
|
+
const totalPageCount = createMemo(() => {
|
|
217
|
+
if (local.pageSize == null) return 1;
|
|
218
|
+
return Math.max(1, Math.ceil(filteredItems().length / local.pageSize));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// 검색이나 필터 변경 시 페이지 리셋
|
|
222
|
+
createEffect(() => {
|
|
223
|
+
// filteredItems에 의존
|
|
224
|
+
void filteredItems();
|
|
225
|
+
setPage(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// 페이지 슬라이스
|
|
229
|
+
const displayItems = createMemo(() => {
|
|
230
|
+
const items = filteredItems();
|
|
231
|
+
if (local.pageSize == null) return items;
|
|
232
|
+
|
|
233
|
+
const start = (page() - 1) * local.pageSize;
|
|
234
|
+
const end = start + local.pageSize;
|
|
235
|
+
return items.slice(start, end);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ─── 선택/토글 핸들러 ─────────────────────────────────
|
|
239
|
+
|
|
240
|
+
const handleSelect = async (item: TValue | undefined) => {
|
|
241
|
+
if (local.disabled) return;
|
|
242
|
+
|
|
243
|
+
// canChange 가드
|
|
244
|
+
if (local.canChange) {
|
|
245
|
+
const allowed = await local.canChange(item);
|
|
246
|
+
if (!allowed) return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 토글: 이미 선택된 값을 다시 클릭하면 선택 해제 (required가 아닐 때만)
|
|
250
|
+
if (item !== undefined && item === local.value && !local.required) {
|
|
251
|
+
local.onValueChange?.(undefined);
|
|
252
|
+
} else {
|
|
253
|
+
local.onValueChange?.(item);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// ─── 아이템 렌더링 ────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
const getItemTemplate = (): ((item: TValue, index: number) => JSX.Element) | undefined => {
|
|
260
|
+
return itemTemplate() as ((item: TValue, index: number) => JSX.Element) | undefined;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const renderItem = (item: TValue, index: number): JSX.Element => {
|
|
264
|
+
const tpl = getItemTemplate();
|
|
265
|
+
if (tpl) {
|
|
266
|
+
return tpl(item, index);
|
|
267
|
+
}
|
|
268
|
+
return <>{String(item)}</>;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ─── 내부 렌더링 ──────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
const SelectListInner: ParentComponent = (innerProps) => {
|
|
274
|
+
// children() resolve로 서브 컴포넌트 등록 트리거
|
|
275
|
+
const resolved = children(() => innerProps.children);
|
|
276
|
+
// resolved는 사용하지 않지만 서브 컴포넌트가 등록되도록 evaluate 필요
|
|
277
|
+
void resolved;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
{...rest}
|
|
282
|
+
data-select-list
|
|
283
|
+
class={twMerge(containerClass, local.class)}
|
|
284
|
+
style={local.style}
|
|
285
|
+
>
|
|
286
|
+
{/* Header: 슬롯 우선, 없으면 props.header 텍스트 */}
|
|
287
|
+
<Show
|
|
288
|
+
when={headerSlot()}
|
|
289
|
+
fallback={
|
|
290
|
+
<Show when={local.header}>
|
|
291
|
+
<div class={headerClass}>{local.header}</div>
|
|
292
|
+
</Show>
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
{headerSlot()!()}
|
|
296
|
+
</Show>
|
|
297
|
+
|
|
298
|
+
{/* Filter: 슬롯 우선, 없으면 getSearchText 있을 때 TextInput */}
|
|
299
|
+
<Show
|
|
300
|
+
when={filterSlot()}
|
|
301
|
+
fallback={
|
|
302
|
+
<Show when={local.getSearchText}>
|
|
303
|
+
<TextInput
|
|
304
|
+
value={searchText()}
|
|
305
|
+
onValueChange={setSearchText}
|
|
306
|
+
placeholder="검색..."
|
|
307
|
+
inset
|
|
308
|
+
disabled={local.disabled}
|
|
309
|
+
/>
|
|
310
|
+
</Show>
|
|
311
|
+
}
|
|
312
|
+
>
|
|
313
|
+
{filterSlot()!()}
|
|
314
|
+
</Show>
|
|
315
|
+
|
|
316
|
+
{/* Pagination */}
|
|
317
|
+
<Show when={local.pageSize != null && totalPageCount() > 1}>
|
|
318
|
+
<Pagination
|
|
319
|
+
page={page()}
|
|
320
|
+
onPageChange={setPage}
|
|
321
|
+
totalPageCount={totalPageCount()}
|
|
322
|
+
size="sm"
|
|
323
|
+
/>
|
|
324
|
+
</Show>
|
|
325
|
+
|
|
326
|
+
{/* List */}
|
|
327
|
+
<List inset>
|
|
328
|
+
{/* 미지정 항목 (required가 아닐 때) */}
|
|
329
|
+
<Show when={!local.required}>
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
use:ripple={!local.disabled}
|
|
333
|
+
class={twMerge(
|
|
334
|
+
listItemBaseClass,
|
|
335
|
+
local.value === undefined && listItemSelectedClass,
|
|
336
|
+
local.disabled && listItemDisabledClass,
|
|
337
|
+
)}
|
|
338
|
+
data-list-item
|
|
339
|
+
role="treeitem"
|
|
340
|
+
aria-selected={local.value === undefined || undefined}
|
|
341
|
+
aria-disabled={local.disabled || undefined}
|
|
342
|
+
tabIndex={local.disabled ? -1 : 0}
|
|
343
|
+
onClick={() => handleSelect(undefined)}
|
|
344
|
+
>
|
|
345
|
+
<span class={clsx(listItemContentClass, textMuted)}>미지정</span>
|
|
346
|
+
</button>
|
|
347
|
+
</Show>
|
|
348
|
+
|
|
349
|
+
{/* 아이템 목록 */}
|
|
350
|
+
<For each={displayItems()}>
|
|
351
|
+
{(item, index) => (
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
use:ripple={!local.disabled}
|
|
355
|
+
class={twMerge(
|
|
356
|
+
listItemBaseClass,
|
|
357
|
+
item === local.value && listItemSelectedClass,
|
|
358
|
+
local.disabled && listItemDisabledClass,
|
|
359
|
+
)}
|
|
360
|
+
data-list-item
|
|
361
|
+
role="treeitem"
|
|
362
|
+
aria-selected={item === local.value || undefined}
|
|
363
|
+
aria-disabled={local.disabled || undefined}
|
|
364
|
+
tabIndex={local.disabled ? -1 : 0}
|
|
365
|
+
onClick={() => handleSelect(item)}
|
|
366
|
+
>
|
|
367
|
+
<span class={listItemContentClass}>{renderItem(item, index())}</span>
|
|
368
|
+
</button>
|
|
369
|
+
)}
|
|
370
|
+
</For>
|
|
371
|
+
</List>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<SelectListContext.Provider value={contextValue}>
|
|
378
|
+
<SelectListInner>{local.children}</SelectListInner>
|
|
379
|
+
</SelectListContext.Provider>
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
SelectList.Header = SelectListHeader;
|
|
384
|
+
SelectList.Filter = SelectListFilter;
|
|
385
|
+
SelectList.ItemTemplate = SelectListItemTemplate;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createContext, useContext, type JSX } from "solid-js";
|
|
2
|
+
import type { SlotAccessor } from "../../../hooks/createSlotSignal";
|
|
3
|
+
|
|
4
|
+
export interface SelectListContextValue {
|
|
5
|
+
/** Header 슬롯 등록 */
|
|
6
|
+
setHeader: (content: SlotAccessor) => void;
|
|
7
|
+
|
|
8
|
+
/** Filter 슬롯 등록 */
|
|
9
|
+
setFilter: (content: SlotAccessor) => void;
|
|
10
|
+
|
|
11
|
+
/** ItemTemplate 등록 */
|
|
12
|
+
setItemTemplate: (fn: ((...args: unknown[]) => JSX.Element) | undefined) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SelectListContext = createContext<SelectListContextValue>();
|
|
16
|
+
|
|
17
|
+
export function useSelectListContext(): SelectListContextValue {
|
|
18
|
+
const context = useContext(SelectListContext);
|
|
19
|
+
if (!context) {
|
|
20
|
+
throw new Error("useSelectListContext는 SelectList 컴포넌트 내부에서만 사용할 수 있습니다");
|
|
21
|
+
}
|
|
22
|
+
return context;
|
|
23
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createMemo, type JSX, mergeProps, splitProps } from "solid-js";
|
|
2
|
+
import { IconEdit, IconSearch } from "@tabler/icons-solidjs";
|
|
3
|
+
import { type SharedDataAccessor } from "../../../providers/shared-data/SharedDataContext";
|
|
4
|
+
import { Select, type SelectProps } from "../select/Select";
|
|
5
|
+
import { Icon } from "../../display/Icon";
|
|
6
|
+
import { useDialog } from "../../disclosure/DialogContext";
|
|
7
|
+
import { type ComponentSize } from "../../../styles/tokens.styles";
|
|
8
|
+
|
|
9
|
+
/** SharedDataSelect Props */
|
|
10
|
+
export interface SharedDataSelectProps<TItem> {
|
|
11
|
+
/** 공유 데이터 접근자 */
|
|
12
|
+
data: SharedDataAccessor<TItem>;
|
|
13
|
+
|
|
14
|
+
/** 현재 선택된 값 */
|
|
15
|
+
value?: unknown;
|
|
16
|
+
/** 값 변경 콜백 */
|
|
17
|
+
onValueChange?: (value: unknown) => void;
|
|
18
|
+
/** 다중 선택 모드 */
|
|
19
|
+
multiple?: boolean;
|
|
20
|
+
/** 필수 입력 */
|
|
21
|
+
required?: boolean;
|
|
22
|
+
/** 비활성화 */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/** 트리거 크기 */
|
|
25
|
+
size?: ComponentSize;
|
|
26
|
+
/** 테두리 없는 스타일 */
|
|
27
|
+
inset?: boolean;
|
|
28
|
+
|
|
29
|
+
/** 항목 필터 함수 */
|
|
30
|
+
filterFn?: (item: TItem, index: number) => boolean;
|
|
31
|
+
/** 선택 모달 컴포넌트 팩토리 */
|
|
32
|
+
modal?: () => JSX.Element;
|
|
33
|
+
/** 편집 모달 컴포넌트 팩토리 */
|
|
34
|
+
editModal?: () => JSX.Element;
|
|
35
|
+
|
|
36
|
+
/** 아이템 렌더링 함수 */
|
|
37
|
+
children: (item: TItem, index: number, depth: number) => JSX.Element;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SharedDataSelect<TItem>(props: SharedDataSelectProps<TItem>): JSX.Element {
|
|
41
|
+
const [local, rest] = splitProps(props, ["data", "filterFn", "modal", "editModal", "children"]);
|
|
42
|
+
|
|
43
|
+
const dialog = useDialog();
|
|
44
|
+
|
|
45
|
+
// filterFn 적용된 items
|
|
46
|
+
const items = createMemo(() => {
|
|
47
|
+
const allItems = local.data.items();
|
|
48
|
+
if (!local.filterFn) return allItems;
|
|
49
|
+
return allItems.filter(local.filterFn);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// modal 열기
|
|
53
|
+
const handleOpenModal = async () => {
|
|
54
|
+
if (!local.modal) return;
|
|
55
|
+
await dialog.show(local.modal, {});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// editModal 열기
|
|
59
|
+
const handleOpenEditModal = async () => {
|
|
60
|
+
if (!local.editModal) return;
|
|
61
|
+
await dialog.show(local.editModal, {});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Select의 discriminated union (multiple: true | false?)과 TItem → unknown 변환을 위해 mergeProps + as 사용
|
|
65
|
+
// getter로 래핑하여 SolidJS 반응성 lint 규칙 충족
|
|
66
|
+
const selectProps = mergeProps(rest, {
|
|
67
|
+
get items() {
|
|
68
|
+
return items();
|
|
69
|
+
},
|
|
70
|
+
get getChildren() {
|
|
71
|
+
if (!local.data.getParentKey) return undefined;
|
|
72
|
+
// eslint-disable-next-line solid/reactivity -- 반환 함수는 Select 내부 JSX tracked scope에서 호출됨
|
|
73
|
+
return (item: TItem) => {
|
|
74
|
+
const key = local.data.getKey(item);
|
|
75
|
+
return items().filter((child) => local.data.getParentKey!(child) === key);
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
get getSearchText() {
|
|
79
|
+
return local.data.getSearchText;
|
|
80
|
+
},
|
|
81
|
+
get getIsHidden() {
|
|
82
|
+
return local.data.getIsHidden;
|
|
83
|
+
},
|
|
84
|
+
}) as unknown as SelectProps;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Select {...selectProps}>
|
|
88
|
+
<Select.ItemTemplate>{local.children}</Select.ItemTemplate>
|
|
89
|
+
{local.modal && (
|
|
90
|
+
<Select.Action onClick={() => void handleOpenModal()} aria-label="검색">
|
|
91
|
+
<Icon icon={IconSearch} size="1em" />
|
|
92
|
+
</Select.Action>
|
|
93
|
+
)}
|
|
94
|
+
{local.editModal && (
|
|
95
|
+
<Select.Action onClick={() => void handleOpenEditModal()} aria-label="편집">
|
|
96
|
+
<Icon icon={IconEdit} size="1em" />
|
|
97
|
+
</Select.Action>
|
|
98
|
+
)}
|
|
99
|
+
</Select>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type JSX, splitProps } from "solid-js";
|
|
2
|
+
import { type SharedDataAccessor } from "../../../providers/shared-data/SharedDataContext";
|
|
3
|
+
import {
|
|
4
|
+
DataSelectButton,
|
|
5
|
+
type DataSelectButtonProps,
|
|
6
|
+
} from "../data-select-button/DataSelectButton";
|
|
7
|
+
import { type ComponentSize } from "../../../styles/tokens.styles";
|
|
8
|
+
|
|
9
|
+
/** SharedDataSelectButton Props */
|
|
10
|
+
export interface SharedDataSelectButtonProps<TItem> {
|
|
11
|
+
/** 공유 데이터 접근자 */
|
|
12
|
+
data: SharedDataAccessor<TItem>;
|
|
13
|
+
|
|
14
|
+
/** 현재 선택된 키 (단일 또는 다중) */
|
|
15
|
+
value?: DataSelectButtonProps<TItem>["value"];
|
|
16
|
+
/** 값 변경 콜백 */
|
|
17
|
+
onValueChange?: DataSelectButtonProps<TItem>["onValueChange"];
|
|
18
|
+
/** 다중 선택 모드 */
|
|
19
|
+
multiple?: boolean;
|
|
20
|
+
/** 필수 입력 */
|
|
21
|
+
required?: boolean;
|
|
22
|
+
/** 비활성화 */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/** 트리거 크기 */
|
|
25
|
+
size?: ComponentSize;
|
|
26
|
+
/** 테두리 없는 스타일 */
|
|
27
|
+
inset?: boolean;
|
|
28
|
+
|
|
29
|
+
/** 선택 모달 컴포넌트 팩토리 */
|
|
30
|
+
modal: () => JSX.Element;
|
|
31
|
+
/** 아이템 렌더링 함수 */
|
|
32
|
+
children: (item: TItem) => JSX.Element;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SharedDataSelectButton<TItem>(
|
|
36
|
+
props: SharedDataSelectButtonProps<TItem>,
|
|
37
|
+
): JSX.Element {
|
|
38
|
+
const [local, rest] = splitProps(props, ["data", "children"]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<DataSelectButton
|
|
42
|
+
load={(keys) => local.data.items().filter((item) => keys.includes(local.data.getKey(item)))}
|
|
43
|
+
renderItem={local.children}
|
|
44
|
+
{...rest}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createMemo, type JSX, Show, splitProps } from "solid-js";
|
|
2
|
+
import { IconExternalLink } from "@tabler/icons-solidjs";
|
|
3
|
+
import { type SharedDataAccessor } from "../../../providers/shared-data/SharedDataContext";
|
|
4
|
+
import { SelectList } from "../select-list/SelectList";
|
|
5
|
+
import { Icon } from "../../display/Icon";
|
|
6
|
+
import { useDialog } from "../../disclosure/DialogContext";
|
|
7
|
+
|
|
8
|
+
/** SharedDataSelectList Props */
|
|
9
|
+
export interface SharedDataSelectListProps<TItem> {
|
|
10
|
+
/** 공유 데이터 접근자 */
|
|
11
|
+
data: SharedDataAccessor<TItem>;
|
|
12
|
+
|
|
13
|
+
/** 현재 선택된 값 */
|
|
14
|
+
value?: TItem;
|
|
15
|
+
/** 값 변경 콜백 */
|
|
16
|
+
onValueChange?: (value: TItem | undefined) => void;
|
|
17
|
+
/** 필수 입력 */
|
|
18
|
+
required?: boolean;
|
|
19
|
+
/** 비활성화 */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
|
|
22
|
+
/** 항목 필터 함수 */
|
|
23
|
+
filterFn?: (item: TItem, index: number) => boolean;
|
|
24
|
+
/** 값 변경 가드 (false 반환 시 변경 차단) */
|
|
25
|
+
canChange?: (item: TItem | undefined) => boolean | Promise<boolean>;
|
|
26
|
+
/** 페이지 크기 (있으면 Pagination 자동 표시) */
|
|
27
|
+
pageSize?: number;
|
|
28
|
+
/** 헤더 텍스트 */
|
|
29
|
+
header?: string;
|
|
30
|
+
/** 관리 모달 컴포넌트 팩토리 */
|
|
31
|
+
modal?: () => JSX.Element;
|
|
32
|
+
|
|
33
|
+
/** 서브 컴포넌트용 children (ItemTemplate 등) */
|
|
34
|
+
children: JSX.Element;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SharedDataSelectList<TItem>(props: SharedDataSelectListProps<TItem>): JSX.Element {
|
|
38
|
+
const [local, rest] = splitProps(props, ["data", "filterFn", "modal", "header", "children"]);
|
|
39
|
+
|
|
40
|
+
const dialog = useDialog();
|
|
41
|
+
|
|
42
|
+
// filterFn 적용된 items
|
|
43
|
+
const items = createMemo(() => {
|
|
44
|
+
const allItems = local.data.items();
|
|
45
|
+
if (!local.filterFn) return allItems;
|
|
46
|
+
return allItems.filter(local.filterFn);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// modal 열기
|
|
50
|
+
const handleOpenModal = async () => {
|
|
51
|
+
if (!local.modal) return;
|
|
52
|
+
await dialog.show(local.modal, {});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<SelectList
|
|
57
|
+
{...rest}
|
|
58
|
+
items={items()}
|
|
59
|
+
getSearchText={local.data.getSearchText}
|
|
60
|
+
getIsHidden={local.data.getIsHidden}
|
|
61
|
+
>
|
|
62
|
+
{/* header + modal 아이콘을 SelectList.Header로 결합 */}
|
|
63
|
+
<Show when={local.header != null || local.modal != null}>
|
|
64
|
+
<SelectList.Header>
|
|
65
|
+
<div class="flex items-center gap-1">
|
|
66
|
+
<Show when={local.header != null}>
|
|
67
|
+
<span>{local.header}</span>
|
|
68
|
+
</Show>
|
|
69
|
+
<Show when={local.modal != null}>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
class="inline-flex items-center justify-center rounded p-0.5 text-base-500 hover:text-primary-500 dark:text-base-400 dark:hover:text-primary-400"
|
|
73
|
+
aria-label="관리"
|
|
74
|
+
onClick={() => void handleOpenModal()}
|
|
75
|
+
>
|
|
76
|
+
<Icon icon={IconExternalLink} size="1em" />
|
|
77
|
+
</button>
|
|
78
|
+
</Show>
|
|
79
|
+
</div>
|
|
80
|
+
</SelectList.Header>
|
|
81
|
+
</Show>
|
|
82
|
+
{local.children}
|
|
83
|
+
</SelectList>
|
|
84
|
+
);
|
|
85
|
+
}
|