@simplysm/solid 13.0.55 → 13.0.57

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 (181) hide show
  1. package/README.md +3 -1
  2. package/dist/components/data/crud-detail/CrudDetail.d.ts +14 -0
  3. package/dist/components/data/crud-detail/CrudDetail.d.ts.map +1 -0
  4. package/dist/components/data/crud-detail/CrudDetail.js +348 -0
  5. package/dist/components/data/crud-detail/CrudDetail.js.map +6 -0
  6. package/dist/components/data/crud-detail/CrudDetailAfter.d.ts +7 -0
  7. package/dist/components/data/crud-detail/CrudDetailAfter.d.ts.map +1 -0
  8. package/dist/components/data/crud-detail/CrudDetailAfter.js +14 -0
  9. package/dist/components/data/crud-detail/CrudDetailAfter.js.map +6 -0
  10. package/dist/components/data/crud-detail/CrudDetailBefore.d.ts +7 -0
  11. package/dist/components/data/crud-detail/CrudDetailBefore.d.ts.map +1 -0
  12. package/dist/components/data/crud-detail/CrudDetailBefore.js +14 -0
  13. package/dist/components/data/crud-detail/CrudDetailBefore.js.map +6 -0
  14. package/dist/components/data/crud-detail/CrudDetailTools.d.ts +7 -0
  15. package/dist/components/data/crud-detail/CrudDetailTools.d.ts.map +1 -0
  16. package/dist/components/data/crud-detail/CrudDetailTools.js +14 -0
  17. package/dist/components/data/crud-detail/CrudDetailTools.js.map +6 -0
  18. package/dist/components/data/crud-detail/types.d.ts +45 -0
  19. package/dist/components/data/crud-detail/types.d.ts.map +1 -0
  20. package/dist/components/data/crud-detail/types.js +1 -0
  21. package/dist/components/data/crud-detail/types.js.map +6 -0
  22. package/dist/components/data/crud-sheet/CrudSheet.d.ts +17 -0
  23. package/dist/components/data/crud-sheet/CrudSheet.d.ts.map +1 -0
  24. package/dist/components/data/crud-sheet/CrudSheet.js +679 -0
  25. package/dist/components/data/crud-sheet/CrudSheet.js.map +6 -0
  26. package/dist/components/data/crud-sheet/CrudSheetColumn.d.ts +5 -0
  27. package/dist/components/data/crud-sheet/CrudSheetColumn.d.ts.map +1 -0
  28. package/dist/components/data/crud-sheet/CrudSheetColumn.js +29 -0
  29. package/dist/components/data/crud-sheet/CrudSheetColumn.js.map +6 -0
  30. package/dist/components/data/crud-sheet/CrudSheetFilter.d.ts +7 -0
  31. package/dist/components/data/crud-sheet/CrudSheetFilter.d.ts.map +1 -0
  32. package/dist/components/data/crud-sheet/CrudSheetFilter.js +14 -0
  33. package/dist/components/data/crud-sheet/CrudSheetFilter.js.map +6 -0
  34. package/dist/components/data/crud-sheet/CrudSheetHeader.d.ts +7 -0
  35. package/dist/components/data/crud-sheet/CrudSheetHeader.d.ts.map +1 -0
  36. package/dist/components/data/crud-sheet/CrudSheetHeader.js +14 -0
  37. package/dist/components/data/crud-sheet/CrudSheetHeader.js.map +6 -0
  38. package/dist/components/data/crud-sheet/CrudSheetTools.d.ts +7 -0
  39. package/dist/components/data/crud-sheet/CrudSheetTools.d.ts.map +1 -0
  40. package/dist/components/data/crud-sheet/CrudSheetTools.js +14 -0
  41. package/dist/components/data/crud-sheet/CrudSheetTools.js.map +6 -0
  42. package/dist/components/data/crud-sheet/types.d.ts +109 -0
  43. package/dist/components/data/crud-sheet/types.d.ts.map +1 -0
  44. package/dist/components/data/crud-sheet/types.js +1 -0
  45. package/dist/components/data/crud-sheet/types.js.map +6 -0
  46. package/dist/components/data/kanban/Kanban.d.ts.map +1 -1
  47. package/dist/components/data/kanban/Kanban.js +137 -138
  48. package/dist/components/data/kanban/Kanban.js.map +2 -2
  49. package/dist/components/data/kanban/KanbanContext.d.ts +5 -1
  50. package/dist/components/data/kanban/KanbanContext.d.ts.map +1 -1
  51. package/dist/components/data/kanban/KanbanContext.js.map +1 -1
  52. package/dist/components/data/list/ListItem.d.ts.map +1 -1
  53. package/dist/components/data/list/ListItem.js +109 -99
  54. package/dist/components/data/list/ListItem.js.map +2 -2
  55. package/dist/components/data/sheet/DataSheet.js +1 -1
  56. package/dist/components/data/sheet/DataSheet.js.map +2 -2
  57. package/dist/components/data/sheet/DataSheet.styles.d.ts.map +1 -1
  58. package/dist/components/data/sheet/DataSheet.styles.js +1 -1
  59. package/dist/components/data/sheet/DataSheet.styles.js.map +1 -1
  60. package/dist/components/disclosure/Dialog.d.ts +16 -10
  61. package/dist/components/disclosure/Dialog.d.ts.map +1 -1
  62. package/dist/components/disclosure/Dialog.js +126 -91
  63. package/dist/components/disclosure/Dialog.js.map +2 -2
  64. package/dist/components/disclosure/DialogContext.d.ts +2 -4
  65. package/dist/components/disclosure/DialogContext.d.ts.map +1 -1
  66. package/dist/components/disclosure/DialogContext.js.map +1 -1
  67. package/dist/components/disclosure/DialogProvider.d.ts.map +1 -1
  68. package/dist/components/disclosure/DialogProvider.js +14 -9
  69. package/dist/components/disclosure/DialogProvider.js.map +2 -2
  70. package/dist/components/disclosure/Dropdown.d.ts +46 -22
  71. package/dist/components/disclosure/Dropdown.d.ts.map +1 -1
  72. package/dist/components/disclosure/Dropdown.js +100 -65
  73. package/dist/components/disclosure/Dropdown.js.map +2 -2
  74. package/dist/components/feedback/notification/NotificationBanner.d.ts.map +1 -1
  75. package/dist/components/feedback/notification/NotificationBanner.js +3 -3
  76. package/dist/components/feedback/notification/NotificationBanner.js.map +1 -1
  77. package/dist/components/feedback/notification/NotificationBell.d.ts.map +1 -1
  78. package/dist/components/feedback/notification/NotificationBell.js +84 -84
  79. package/dist/components/feedback/notification/NotificationBell.js.map +2 -2
  80. package/dist/components/form-control/combobox/Combobox.d.ts +6 -3
  81. package/dist/components/form-control/combobox/Combobox.d.ts.map +1 -1
  82. package/dist/components/form-control/combobox/Combobox.js +150 -168
  83. package/dist/components/form-control/combobox/Combobox.js.map +2 -2
  84. package/dist/components/form-control/combobox/ComboboxContext.d.ts +3 -0
  85. package/dist/components/form-control/combobox/ComboboxContext.d.ts.map +1 -1
  86. package/dist/components/form-control/combobox/ComboboxContext.js.map +1 -1
  87. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts +0 -2
  88. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts.map +1 -1
  89. package/dist/components/form-control/date-range-picker/DateRangePicker.js +9 -17
  90. package/dist/components/form-control/date-range-picker/DateRangePicker.js.map +2 -2
  91. package/dist/components/form-control/field/Field.styles.d.ts.map +1 -1
  92. package/dist/components/form-control/field/Field.styles.js +2 -1
  93. package/dist/components/form-control/field/Field.styles.js.map +1 -1
  94. package/dist/components/form-control/field/NumberInput.d.ts +15 -5
  95. package/dist/components/form-control/field/NumberInput.d.ts.map +1 -1
  96. package/dist/components/form-control/field/NumberInput.js +181 -141
  97. package/dist/components/form-control/field/NumberInput.js.map +2 -2
  98. package/dist/components/form-control/field/TextInput.d.ts +9 -5
  99. package/dist/components/form-control/field/TextInput.d.ts.map +1 -1
  100. package/dist/components/form-control/field/TextInput.js +199 -154
  101. package/dist/components/form-control/field/TextInput.js.map +2 -2
  102. package/dist/components/form-control/select/Select.d.ts +3 -3
  103. package/dist/components/form-control/select/Select.d.ts.map +1 -1
  104. package/dist/components/form-control/select/Select.js +116 -100
  105. package/dist/components/form-control/select/Select.js.map +2 -2
  106. package/dist/components/form-control/select/SelectContext.d.ts +9 -1
  107. package/dist/components/form-control/select/SelectContext.d.ts.map +1 -1
  108. package/dist/components/form-control/select/SelectContext.js.map +1 -1
  109. package/dist/components/form-control/select/SelectItem.d.ts.map +1 -1
  110. package/dist/components/form-control/select/SelectItem.js +77 -67
  111. package/dist/components/form-control/select/SelectItem.js.map +2 -2
  112. package/dist/components/layout/topbar/TopbarMenu.d.ts.map +1 -1
  113. package/dist/components/layout/topbar/TopbarMenu.js +63 -57
  114. package/dist/components/layout/topbar/TopbarMenu.js.map +2 -2
  115. package/dist/components/layout/topbar/TopbarUser.d.ts.map +1 -1
  116. package/dist/components/layout/topbar/TopbarUser.js +53 -54
  117. package/dist/components/layout/topbar/TopbarUser.js.map +2 -2
  118. package/dist/hooks/createControllableStore.d.ts +29 -0
  119. package/dist/hooks/createControllableStore.d.ts.map +1 -0
  120. package/dist/hooks/createControllableStore.js +19 -0
  121. package/dist/hooks/createControllableStore.js.map +6 -0
  122. package/dist/index.d.ts +5 -1
  123. package/dist/index.d.ts.map +1 -1
  124. package/dist/index.js +6 -2
  125. package/dist/index.js.map +1 -1
  126. package/dist/styles/patterns.styles.d.ts.map +1 -1
  127. package/dist/styles/patterns.styles.js +7 -1
  128. package/dist/styles/patterns.styles.js.map +1 -1
  129. package/docs/data-components.md +428 -0
  130. package/docs/disclosure.md +65 -35
  131. package/docs/form-controls.md +18 -3
  132. package/docs/helpers.md +0 -39
  133. package/docs/hooks.md +39 -0
  134. package/package.json +4 -3
  135. package/src/components/data/crud-detail/CrudDetail.tsx +346 -0
  136. package/src/components/data/crud-detail/CrudDetailAfter.tsx +19 -0
  137. package/src/components/data/crud-detail/CrudDetailBefore.tsx +19 -0
  138. package/src/components/data/crud-detail/CrudDetailTools.tsx +19 -0
  139. package/src/components/data/crud-detail/types.ts +58 -0
  140. package/src/components/data/crud-sheet/CrudSheet.tsx +628 -0
  141. package/src/components/data/crud-sheet/CrudSheetColumn.tsx +34 -0
  142. package/src/components/data/crud-sheet/CrudSheetFilter.tsx +21 -0
  143. package/src/components/data/crud-sheet/CrudSheetHeader.tsx +19 -0
  144. package/src/components/data/crud-sheet/CrudSheetTools.tsx +21 -0
  145. package/src/components/data/crud-sheet/types.ts +133 -0
  146. package/src/components/data/kanban/Kanban.tsx +72 -65
  147. package/src/components/data/kanban/KanbanContext.ts +7 -1
  148. package/src/components/data/list/ListItem.tsx +31 -18
  149. package/src/components/data/sheet/DataSheet.styles.ts +1 -1
  150. package/src/components/data/sheet/DataSheet.tsx +1 -1
  151. package/src/components/disclosure/Dialog.tsx +143 -105
  152. package/src/components/disclosure/DialogContext.ts +2 -4
  153. package/src/components/disclosure/DialogProvider.tsx +4 -2
  154. package/src/components/disclosure/Dropdown.tsx +174 -86
  155. package/src/components/feedback/notification/NotificationBanner.tsx +3 -9
  156. package/src/components/feedback/notification/NotificationBell.tsx +51 -57
  157. package/src/components/form-control/combobox/Combobox.tsx +109 -134
  158. package/src/components/form-control/combobox/ComboboxContext.ts +4 -1
  159. package/src/components/form-control/date-range-picker/DateRangePicker.tsx +6 -16
  160. package/src/components/form-control/field/Field.styles.ts +1 -0
  161. package/src/components/form-control/field/NumberInput.tsx +131 -88
  162. package/src/components/form-control/field/TextInput.tsx +139 -88
  163. package/src/components/form-control/select/Select.tsx +85 -67
  164. package/src/components/form-control/select/SelectContext.ts +12 -1
  165. package/src/components/form-control/select/SelectItem.tsx +39 -18
  166. package/src/components/layout/topbar/TopbarMenu.tsx +52 -55
  167. package/src/components/layout/topbar/TopbarUser.tsx +28 -31
  168. package/src/hooks/createControllableStore.ts +47 -0
  169. package/src/index.ts +5 -1
  170. package/src/styles/patterns.styles.ts +7 -1
  171. package/tailwind.css +4 -0
  172. package/dist/helpers/splitSlots.d.ts +0 -25
  173. package/dist/helpers/splitSlots.d.ts.map +0 -1
  174. package/dist/helpers/splitSlots.js +0 -25
  175. package/dist/helpers/splitSlots.js.map +0 -6
  176. package/dist/hooks/createItemTemplate.d.ts +0 -17
  177. package/dist/hooks/createItemTemplate.d.ts.map +0 -1
  178. package/dist/hooks/createItemTemplate.js +0 -40
  179. package/dist/hooks/createItemTemplate.js.map +0 -6
  180. package/src/helpers/splitSlots.ts +0 -51
  181. package/src/hooks/createItemTemplate.tsx +0 -42
@@ -0,0 +1,628 @@
1
+ import {
2
+ children,
3
+ createEffect,
4
+ createMemo,
5
+ createSignal,
6
+ For,
7
+ type JSX,
8
+ Show,
9
+ splitProps,
10
+ useContext,
11
+ } from "solid-js";
12
+ import { createStore, produce, reconcile } from "solid-js/store";
13
+ import { createControllableStore } from "../../../hooks/createControllableStore";
14
+ import { objClone } from "@simplysm/core-common";
15
+ import "@simplysm/core-common"; // register extensions
16
+ import type { SortingDef } from "../sheet/types";
17
+ import { DataSheet } from "../sheet/DataSheet";
18
+ import { DataSheetColumn } from "../sheet/DataSheetColumn";
19
+ import { BusyContainer } from "../../feedback/busy/BusyContainer";
20
+ import { useNotification } from "../../feedback/notification/NotificationContext";
21
+ import { Button } from "../../form-control/Button";
22
+ import { Icon } from "../../display/Icon";
23
+ import { FormGroup } from "../../layout/FormGroup";
24
+ import { TopbarContext, createTopbarActions } from "../../layout/topbar/TopbarContext";
25
+ import { useDialogInstance } from "../../disclosure/DialogInstanceContext";
26
+ import { Dialog } from "../../disclosure/Dialog";
27
+ import { Link } from "../../display/Link";
28
+ import { createEventListener } from "@solid-primitives/event-listener";
29
+ import clsx from "clsx";
30
+ import {
31
+ IconDeviceFloppy,
32
+ IconFileExcel,
33
+ IconPlus,
34
+ IconRefresh,
35
+ IconSearch,
36
+ IconTrash,
37
+ IconTrashOff,
38
+ IconUpload,
39
+ } from "@tabler/icons-solidjs";
40
+ import { isCrudSheetColumnDef, CrudSheetColumn } from "./CrudSheetColumn";
41
+ import { isCrudSheetFilterDef, CrudSheetFilter } from "./CrudSheetFilter";
42
+ import { isCrudSheetToolsDef, CrudSheetTools } from "./CrudSheetTools";
43
+ import { isCrudSheetHeaderDef, CrudSheetHeader } from "./CrudSheetHeader";
44
+ import type {
45
+ CrudSheetColumnDef,
46
+ CrudSheetContext,
47
+ CrudSheetFilterDef,
48
+ CrudSheetHeaderDef,
49
+ CrudSheetProps,
50
+ CrudSheetToolsDef,
51
+ SearchResult,
52
+ } from "./types";
53
+
54
+ interface CrudSheetComponent {
55
+ <TItem, TFilter extends Record<string, any>>(props: CrudSheetProps<TItem, TFilter>): JSX.Element;
56
+ Column: typeof CrudSheetColumn;
57
+ Filter: typeof CrudSheetFilter;
58
+ Tools: typeof CrudSheetTools;
59
+ Header: typeof CrudSheetHeader;
60
+ }
61
+
62
+ const CrudSheetBase = <TItem, TFilter extends Record<string, any>>(
63
+ props: CrudSheetProps<TItem, TFilter>,
64
+ ) => {
65
+ const [local, _rest] = splitProps(props, [
66
+ "search",
67
+ "getItemKey",
68
+ "persistKey",
69
+ "itemsPerPage",
70
+ "editable",
71
+ "itemEditable",
72
+ "itemDeletable",
73
+ "filterInitial",
74
+ "items",
75
+ "onItemsChange",
76
+ "inlineEdit",
77
+ "modalEdit",
78
+ "excel",
79
+ "selectMode",
80
+ "onSelect",
81
+ "hideAutoTools",
82
+ "class",
83
+ "children",
84
+ ]);
85
+
86
+ const noti = useNotification();
87
+ const topbarCtx = useContext(TopbarContext);
88
+ const dialogInstance = useDialogInstance();
89
+ const isModal = dialogInstance !== undefined;
90
+ const isSelectMode = () => local.selectMode != null;
91
+ const canEdit = () => (isSelectMode() ? false : (local.editable ?? true));
92
+
93
+ // -- Children Resolution --
94
+ const resolved = children(() => local.children);
95
+ const defs = createMemo(() => {
96
+ const arr = resolved.toArray();
97
+ return {
98
+ filter: arr.find(isCrudSheetFilterDef) as CrudSheetFilterDef<TFilter> | undefined,
99
+ columns: arr.filter(isCrudSheetColumnDef) as unknown as CrudSheetColumnDef<TItem>[],
100
+ tools: arr.find(isCrudSheetToolsDef) as CrudSheetToolsDef<TItem> | undefined,
101
+ header: arr.find(isCrudSheetHeaderDef) as CrudSheetHeaderDef | undefined,
102
+ };
103
+ });
104
+
105
+ // -- State --
106
+ const [items, setItems] = createControllableStore<TItem[]>({
107
+ value: () => local.items ?? [],
108
+ onChange: () => local.onItemsChange,
109
+ });
110
+ let originalItems: TItem[] = [];
111
+
112
+ // eslint-disable-next-line solid/reactivity -- filterInitial은 초기값으로만 사용
113
+ const [filter, setFilter] = createStore<TFilter>((local.filterInitial ?? {}) as TFilter);
114
+ const [lastFilter, setLastFilter] = createSignal<TFilter>(objClone(filter));
115
+
116
+ const [page, setPage] = createSignal(1);
117
+ const [totalPageCount, setTotalPageCount] = createSignal(0);
118
+ const [sorts, setSorts] = createSignal<SortingDef[]>([]);
119
+
120
+ const [busyCount, setBusyCount] = createSignal(0);
121
+ const [ready, setReady] = createSignal(false);
122
+
123
+ const [selectedItems, setSelectedItems] = createSignal<TItem[]>([]);
124
+
125
+ let formRef: HTMLFormElement | undefined;
126
+
127
+ // -- Auto Refresh Effect --
128
+ createEffect(() => {
129
+ const currLastFilter = lastFilter();
130
+ const currSorts = sorts();
131
+ const currPage = page();
132
+
133
+ queueMicrotask(async () => {
134
+ setBusyCount((c) => c + 1);
135
+ await noti.try(async () => {
136
+ await refresh(currLastFilter, currSorts, currPage);
137
+ }, "조회 실패");
138
+ setBusyCount((c) => c - 1);
139
+ setReady(true);
140
+ });
141
+ });
142
+
143
+ async function refresh(currLastFilter: TFilter, currSorts: SortingDef[], currPage: number) {
144
+ const usePagination = local.itemsPerPage != null;
145
+ const result: SearchResult<TItem> = await local.search(
146
+ currLastFilter,
147
+ usePagination ? currPage : 0,
148
+ currSorts,
149
+ );
150
+ setItems(reconcile(result.items));
151
+ originalItems = objClone(result.items);
152
+ setTotalPageCount(result.pageCount ?? 0);
153
+ }
154
+
155
+ /* eslint-disable solid/reactivity -- 이벤트 핸들러에서만 호출, store 즉시 읽기 */
156
+ function getItemDiffs() {
157
+ return (items as unknown as TItem[]).oneWayDiffs(originalItems, ((item: TItem) => {
158
+ return local.getItemKey(item);
159
+ }) as (item: TItem) => keyof TItem);
160
+ }
161
+ /* eslint-enable solid/reactivity */
162
+
163
+ // -- Filter --
164
+ function handleFilterSubmit(e: Event) {
165
+ e.preventDefault();
166
+ setPage(1);
167
+ setLastFilter(() => objClone(filter));
168
+ }
169
+
170
+ function handleRefresh() {
171
+ setLastFilter(() => ({ ...lastFilter() }));
172
+ }
173
+
174
+ // -- Inline Edit --
175
+ function handleAddRow() {
176
+ if (!local.inlineEdit) return;
177
+ setItems(
178
+ produce((draft) => {
179
+ draft.unshift(local.inlineEdit!.newItem());
180
+ }),
181
+ );
182
+ }
183
+
184
+ function handleToggleDelete(item: TItem, index: number) {
185
+ if (!(local.itemDeletable?.(item) ?? true)) return;
186
+ if (local.inlineEdit?.deleteProp == null) return;
187
+ const dp = local.inlineEdit.deleteProp;
188
+
189
+ if (local.getItemKey(item) == null) {
190
+ setItems(
191
+ produce((draft) => {
192
+ draft.splice(index, 1);
193
+ }),
194
+ );
195
+ return;
196
+ }
197
+
198
+ setItems(index as any, dp as any, !(item[dp] as boolean) as any);
199
+ }
200
+
201
+ async function handleSave() {
202
+ if (busyCount() > 0) return;
203
+ if (!canEdit()) return;
204
+ if (!local.inlineEdit) return;
205
+
206
+ const diffs = getItemDiffs();
207
+
208
+ if (diffs.length === 0) {
209
+ noti.info("안내", "변경사항이 없습니다.");
210
+ return;
211
+ }
212
+
213
+ const currLastFilter = lastFilter();
214
+ const currSorts = sorts();
215
+ const currPage = page();
216
+
217
+ setBusyCount((c) => c + 1);
218
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 refresh 호출
219
+ await noti.try(async () => {
220
+ await local.inlineEdit!.submit(diffs);
221
+ noti.success("저장 완료", "저장되었습니다.");
222
+ await refresh(currLastFilter, currSorts, currPage);
223
+ }, "저장 실패");
224
+ setBusyCount((c) => c - 1);
225
+ }
226
+
227
+ async function handleFormSubmit(e: Event) {
228
+ e.preventDefault();
229
+ await handleSave();
230
+ }
231
+
232
+ // -- Modal Edit --
233
+ async function handleEditItem(item?: TItem) {
234
+ if (!local.modalEdit) return;
235
+ const result = await local.modalEdit.editItem(item);
236
+ if (!result) return;
237
+
238
+ setBusyCount((c) => c + 1);
239
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 refresh 호출
240
+ await noti.try(async () => {
241
+ await refresh(lastFilter(), sorts(), page());
242
+ }, "조회 실패");
243
+ setBusyCount((c) => c - 1);
244
+ }
245
+
246
+ async function handleDeleteItems() {
247
+ if (!local.modalEdit?.deleteItems) return;
248
+ const result = await local.modalEdit.deleteItems(selectedItems());
249
+ if (!result) return;
250
+
251
+ setBusyCount((c) => c + 1);
252
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 refresh 호출
253
+ await noti.try(async () => {
254
+ await refresh(lastFilter(), sorts(), page());
255
+ noti.success("삭제 완료", "삭제되었습니다.");
256
+ }, "삭제 실패");
257
+ setBusyCount((c) => c - 1);
258
+ }
259
+
260
+ // -- Excel --
261
+ async function handleExcelDownload() {
262
+ if (!local.excel) return;
263
+
264
+ setBusyCount((c) => c + 1);
265
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 호출
266
+ await noti.try(async () => {
267
+ const result = await local.search(lastFilter(), 0, sorts());
268
+ await local.excel!.download(result.items);
269
+ }, "엑셀 다운로드 실패");
270
+ setBusyCount((c) => c - 1);
271
+ }
272
+
273
+ function handleExcelUpload() {
274
+ if (!local.excel?.upload) return;
275
+
276
+ const input = document.createElement("input");
277
+ input.type = "file";
278
+ input.accept = ".xlsx";
279
+ input.onchange = async () => {
280
+ const file = input.files?.[0];
281
+ if (file == null) return;
282
+
283
+ setBusyCount((c) => c + 1);
284
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 호출
285
+ await noti.try(async () => {
286
+ await local.excel!.upload!(file);
287
+ noti.success("완료", "엑셀 업로드가 완료되었습니다.");
288
+ await refresh(lastFilter(), sorts(), page());
289
+ }, "엑셀 업로드 실패");
290
+ setBusyCount((c) => c - 1);
291
+ };
292
+ input.click();
293
+ }
294
+
295
+ // -- Select Mode --
296
+ function handleSelectConfirm() {
297
+ local.onSelect?.({
298
+ items: selectedItems(),
299
+ keys: selectedItems()
300
+ .map((item) => local.getItemKey(item))
301
+ .filter((k): k is string | number => k != null),
302
+ });
303
+ }
304
+
305
+ function handleSelectCancel() {
306
+ local.onSelect?.({ items: [], keys: [] });
307
+ }
308
+
309
+ // -- Keyboard Shortcuts --
310
+ createEventListener(document, "keydown", (e: KeyboardEvent) => {
311
+ if (e.ctrlKey && e.key === "s" && !isSelectMode()) {
312
+ e.preventDefault();
313
+ formRef?.requestSubmit();
314
+ }
315
+ if (e.ctrlKey && e.altKey && e.key === "l") {
316
+ e.preventDefault();
317
+ handleRefresh();
318
+ }
319
+ });
320
+
321
+ // -- Topbar Actions --
322
+ if (topbarCtx) {
323
+ createTopbarActions(() => (
324
+ <>
325
+ <Show when={canEdit() && local.inlineEdit}>
326
+ <Button
327
+ size="lg"
328
+ variant="ghost"
329
+ theme="primary"
330
+ onClick={() => formRef?.requestSubmit()}
331
+ >
332
+ <Icon icon={IconDeviceFloppy} class="mr-1" />
333
+ 저장
334
+ </Button>
335
+ </Show>
336
+ <Button size="lg" variant="ghost" theme="info" onClick={handleRefresh}>
337
+ <Icon icon={IconRefresh} class="mr-1" />
338
+ 새로고침
339
+ </Button>
340
+ </>
341
+ ));
342
+ }
343
+
344
+ // -- Context for Tools --
345
+ const ctx: CrudSheetContext<TItem> = {
346
+ items: () => items as unknown as TItem[],
347
+ selectedItems,
348
+ page,
349
+ sorts,
350
+ busy: () => busyCount() > 0,
351
+ hasChanges: () => {
352
+ if (!local.inlineEdit) return false;
353
+ return getItemDiffs().length > 0;
354
+ },
355
+ save: handleSave,
356
+ refresh: async () => {
357
+ handleRefresh();
358
+ await Promise.resolve();
359
+ },
360
+ addItem: handleAddRow,
361
+ setPage,
362
+ setSorts,
363
+ };
364
+
365
+ // -- Render --
366
+ const deleteProp = () => local.inlineEdit?.deleteProp;
367
+
368
+ return (
369
+ <>
370
+ {/* Modal mode: Dialog.Action (refresh button in header) */}
371
+ <Show when={isModal}>
372
+ <Dialog.Action>
373
+ <button
374
+ class="flex items-center px-2 text-base-400 hover:text-base-600"
375
+ onClick={handleRefresh}
376
+ >
377
+ <Icon icon={IconRefresh} />
378
+ </button>
379
+ </Dialog.Action>
380
+ </Show>
381
+
382
+ <BusyContainer
383
+ ready={ready()}
384
+ busy={busyCount() > 0}
385
+ class={clsx("flex h-full flex-col", local.class)}
386
+ >
387
+ {/* Control mode: inline save/refresh bar */}
388
+ <Show when={!isModal && !topbarCtx && canEdit() && local.inlineEdit}>
389
+ <div class="flex gap-2 p-2 pb-0">
390
+ <Button
391
+ size="sm"
392
+ theme="primary"
393
+ variant="ghost"
394
+ onClick={() => formRef?.requestSubmit()}
395
+ >
396
+ <Icon icon={IconDeviceFloppy} class="mr-1" />
397
+ 저장
398
+ </Button>
399
+ <Button size="sm" theme="info" variant="ghost" onClick={handleRefresh}>
400
+ <Icon icon={IconRefresh} class="mr-1" />
401
+ 새로고침
402
+ </Button>
403
+ </div>
404
+ </Show>
405
+
406
+ {/* Header (optional) */}
407
+ <Show when={defs().header}>{(headerDef) => headerDef().children}</Show>
408
+
409
+ {/* Filter */}
410
+ <Show when={defs().filter}>
411
+ {(filterDef) => (
412
+ <form class="p-2" onSubmit={handleFilterSubmit}>
413
+ <FormGroup inline>
414
+ <FormGroup.Item>
415
+ <Button type="submit" theme="info" variant="solid">
416
+ <Icon icon={IconSearch} class="mr-1" />
417
+ 조회
418
+ </Button>
419
+ </FormGroup.Item>
420
+ {filterDef().children(filter, setFilter)}
421
+ </FormGroup>
422
+ </form>
423
+ )}
424
+ </Show>
425
+
426
+ {/* Toolbar */}
427
+ <Show when={!isSelectMode()}>
428
+ <div class="flex gap-2 p-2 pb-0">
429
+ <Show when={!local.hideAutoTools}>
430
+ {/* Inline edit buttons */}
431
+ <Show when={canEdit() && local.inlineEdit}>
432
+ <Button size="sm" theme="primary" variant="ghost" onClick={handleAddRow}>
433
+ <Icon icon={IconPlus} class="mr-1" />행 추가
434
+ </Button>
435
+ </Show>
436
+
437
+ {/* Modal edit buttons */}
438
+ <Show when={canEdit() && local.modalEdit}>
439
+ <Button
440
+ size="sm"
441
+ theme="primary"
442
+ variant="ghost"
443
+ onClick={() => void handleEditItem()}
444
+ >
445
+ <Icon icon={IconPlus} class="mr-1" />
446
+ 등록
447
+ </Button>
448
+ </Show>
449
+ <Show when={canEdit() && local.modalEdit?.deleteItems}>
450
+ <Button
451
+ size="sm"
452
+ theme="danger"
453
+ variant="ghost"
454
+ onClick={handleDeleteItems}
455
+ disabled={
456
+ selectedItems().length === 0 ||
457
+ !selectedItems().some((item) => local.itemDeletable?.(item) ?? true)
458
+ }
459
+ >
460
+ <Icon icon={IconTrash} class="mr-1" />
461
+ 선택 삭제
462
+ </Button>
463
+ </Show>
464
+
465
+ {/* Excel buttons */}
466
+ <Show when={canEdit() && local.excel?.upload}>
467
+ <Button size="sm" theme="success" variant="ghost" onClick={handleExcelUpload}>
468
+ <Icon icon={IconUpload} class="mr-1" />
469
+ 엑셀 업로드
470
+ </Button>
471
+ </Show>
472
+ <Show when={local.excel}>
473
+ <Button size="sm" theme="success" variant="ghost" onClick={handleExcelDownload}>
474
+ <Icon icon={IconFileExcel} class="mr-1" />
475
+ 엑셀 다운로드
476
+ </Button>
477
+ </Show>
478
+ </Show>
479
+
480
+ {/* Custom tools */}
481
+ <Show when={defs().tools}>{(toolsDef) => toolsDef().children(ctx)}</Show>
482
+ </div>
483
+ </Show>
484
+
485
+ {/* DataSheet */}
486
+ <form ref={formRef} class="flex-1 overflow-hidden p-2 pt-1" onSubmit={handleFormSubmit}>
487
+ <DataSheet
488
+ class="h-full"
489
+ items={items}
490
+ persistKey={local.persistKey != null ? `${local.persistKey}-sheet` : undefined}
491
+ page={local.itemsPerPage != null ? page() : undefined}
492
+ onPageChange={setPage}
493
+ totalPageCount={totalPageCount()}
494
+ itemsPerPage={local.itemsPerPage}
495
+ sorts={sorts()}
496
+ onSortsChange={setSorts}
497
+ selectMode={
498
+ isSelectMode()
499
+ ? local.selectMode === "multi"
500
+ ? "multiple"
501
+ : "single"
502
+ : local.modalEdit?.deleteItems != null
503
+ ? "multiple"
504
+ : undefined
505
+ }
506
+ selectedItems={selectedItems()}
507
+ onSelectedItemsChange={setSelectedItems}
508
+ autoSelect={isSelectMode() && local.selectMode === "single" ? "click" : undefined}
509
+ cellClass={(item, _colKey) => {
510
+ const dp = deleteProp();
511
+ if (dp != null && Boolean((item as Record<string, unknown>)[dp])) {
512
+ return clsx("line-through");
513
+ }
514
+ return undefined;
515
+ }}
516
+ >
517
+ {/* Auto delete column */}
518
+ <Show when={deleteProp() != null && canEdit() ? deleteProp() : undefined}>
519
+ {(dp) => (
520
+ <DataSheetColumn<TItem>
521
+ key="__delete"
522
+ header=""
523
+ fixed
524
+ sortable={false}
525
+ resizable={false}
526
+ >
527
+ {(dsCtx) => (
528
+ <div class="flex items-center justify-center px-1 py-0.5">
529
+ <Link
530
+ theme="danger"
531
+ disabled={!(local.itemDeletable?.(dsCtx.item) ?? true)}
532
+ onClick={() => handleToggleDelete(dsCtx.item, dsCtx.index)}
533
+ >
534
+ <Icon
535
+ icon={
536
+ Boolean((dsCtx.item as Record<string, unknown>)[dp()])
537
+ ? IconTrashOff
538
+ : IconTrash
539
+ }
540
+ />
541
+ </Link>
542
+ </div>
543
+ )}
544
+ </DataSheetColumn>
545
+ )}
546
+ </Show>
547
+
548
+ {/* User-defined columns -- map CrudSheetColumn to DataSheetColumn */}
549
+ <For each={defs().columns}>
550
+ {(col) => (
551
+ <DataSheetColumn<TItem>
552
+ key={col.key}
553
+ header={col.header}
554
+ headerContent={col.headerContent}
555
+ headerStyle={col.headerStyle}
556
+ summary={col.summary}
557
+ tooltip={col.tooltip}
558
+ fixed={col.fixed}
559
+ hidden={col.hidden}
560
+ collapse={col.collapse}
561
+ width={col.width}
562
+ class={col.class}
563
+ sortable={col.sortable}
564
+ resizable={col.resizable}
565
+ >
566
+ {(dsCtx) => {
567
+ const crudCtx = {
568
+ ...dsCtx,
569
+ setItem: <TKey extends keyof TItem>(key: TKey, value: TItem[TKey]) => {
570
+ setItems(dsCtx.index as any, key as any, value as any);
571
+ },
572
+ };
573
+
574
+ // modalEdit editable column -- wrap with edit link
575
+ if (
576
+ local.modalEdit &&
577
+ col.editable &&
578
+ canEdit() &&
579
+ (local.itemEditable?.(dsCtx.item) ?? true)
580
+ ) {
581
+ return (
582
+ <Link
583
+ class="flex w-full"
584
+ onClick={(e) => {
585
+ e.preventDefault();
586
+ e.stopPropagation();
587
+ void handleEditItem(dsCtx.item);
588
+ }}
589
+ >
590
+ {col.cell(crudCtx)}
591
+ </Link>
592
+ );
593
+ }
594
+
595
+ return col.cell(crudCtx);
596
+ }}
597
+ </DataSheetColumn>
598
+ )}
599
+ </For>
600
+ </DataSheet>
601
+ </form>
602
+
603
+ {/* Select mode bottom bar */}
604
+ <Show when={isModal && isSelectMode()}>
605
+ <div class="flex gap-2 border-t p-2">
606
+ <div class="flex-1" />
607
+ <Show when={selectedItems().length > 0}>
608
+ <Button size="sm" theme="danger" onClick={handleSelectCancel}>
609
+ {local.selectMode === "multi" ? "모두" : "선택"} 해제
610
+ </Button>
611
+ </Show>
612
+ <Show when={local.selectMode === "multi"}>
613
+ <Button size="sm" theme="primary" onClick={handleSelectConfirm}>
614
+ 확인({selectedItems().length})
615
+ </Button>
616
+ </Show>
617
+ </div>
618
+ </Show>
619
+ </BusyContainer>
620
+ </>
621
+ );
622
+ };
623
+
624
+ export const CrudSheet = CrudSheetBase as unknown as CrudSheetComponent;
625
+ CrudSheet.Column = CrudSheetColumn;
626
+ CrudSheet.Filter = CrudSheetFilter;
627
+ CrudSheet.Tools = CrudSheetTools;
628
+ CrudSheet.Header = CrudSheetHeader;
@@ -0,0 +1,34 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudSheetColumnDef, CrudSheetColumnProps } from "./types";
3
+ import { normalizeHeader } from "../sheet/sheetUtils";
4
+
5
+ export function isCrudSheetColumnDef(value: unknown): value is CrudSheetColumnDef<unknown> {
6
+ return (
7
+ value != null &&
8
+ typeof value === "object" &&
9
+ (value as Record<string, unknown>)["__type"] === "crud-sheet-column"
10
+ );
11
+ }
12
+
13
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
14
+ export function CrudSheetColumn<TItem>(props: CrudSheetColumnProps<TItem>): JSX.Element {
15
+ return {
16
+ __type: "crud-sheet-column",
17
+ key: props.key,
18
+ header: normalizeHeader(props.header),
19
+ headerContent: props.headerContent,
20
+ headerStyle: props.headerStyle,
21
+ summary: props.summary,
22
+ tooltip: props.tooltip,
23
+ cell: props.children,
24
+ class: props.class,
25
+ fixed: props.fixed ?? false,
26
+ hidden: props.hidden ?? false,
27
+ collapse: props.collapse ?? false,
28
+ width: props.width,
29
+ sortable: props.sortable ?? true,
30
+ resizable: props.resizable ?? true,
31
+ editable: props.editable ?? false,
32
+ } as unknown as JSX.Element;
33
+ }
34
+ /* eslint-enable solid/reactivity */
@@ -0,0 +1,21 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudSheetFilterDef } from "./types";
3
+
4
+ export function isCrudSheetFilterDef(value: unknown): value is CrudSheetFilterDef<any> {
5
+ return (
6
+ value != null &&
7
+ typeof value === "object" &&
8
+ (value as Record<string, unknown>)["__type"] === "crud-sheet-filter"
9
+ );
10
+ }
11
+
12
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
13
+ export function CrudSheetFilter<TFilter>(props: {
14
+ children: (filter: TFilter, setFilter: any) => JSX.Element;
15
+ }): JSX.Element {
16
+ return {
17
+ __type: "crud-sheet-filter",
18
+ children: props.children,
19
+ } as unknown as JSX.Element;
20
+ }
21
+ /* eslint-enable solid/reactivity */
@@ -0,0 +1,19 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudSheetHeaderDef } from "./types";
3
+
4
+ export function isCrudSheetHeaderDef(value: unknown): value is CrudSheetHeaderDef {
5
+ return (
6
+ value != null &&
7
+ typeof value === "object" &&
8
+ (value as Record<string, unknown>)["__type"] === "crud-sheet-header"
9
+ );
10
+ }
11
+
12
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
13
+ export function CrudSheetHeader(props: { children: JSX.Element }): JSX.Element {
14
+ return {
15
+ __type: "crud-sheet-header",
16
+ children: props.children,
17
+ } as unknown as JSX.Element;
18
+ }
19
+ /* eslint-enable solid/reactivity */