@simplysm/solid 13.0.55 → 13.0.56

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
@@ -31,6 +31,8 @@ Text input field with format mask and IME (Korean, etc.) composition support.
31
31
 
32
32
  ```tsx
33
33
  import { TextInput } from "@simplysm/solid";
34
+ import { IconSearch } from "@tabler/icons-solidjs";
35
+ import { Icon } from "@simplysm/solid";
34
36
 
35
37
  // Basic usage
36
38
  <TextInput value={name()} onValueChange={setName} placeholder="Enter name" />
@@ -41,6 +43,11 @@ import { TextInput } from "@simplysm/solid";
41
43
  // Format mask (e.g., phone number)
42
44
  <TextInput format="XXX-XXXX-XXXX" value={phone()} onValueChange={setPhone} />
43
45
 
46
+ // With prefix
47
+ <TextInput value={query()} onValueChange={setQuery}>
48
+ <TextInput.Prefix><Icon icon={IconSearch} /></TextInput.Prefix>
49
+ </TextInput>
50
+
44
51
  // With validation
45
52
  <TextInput required minLength={3} value={name()} onValueChange={setName} />
46
53
  <TextInput
@@ -56,7 +63,6 @@ import { TextInput } from "@simplysm/solid";
56
63
  | `onValueChange` | `(value: string) => void` | - | Value change callback |
57
64
  | `type` | `"text" \| "password" \| "email"` | `"text"` | Input type |
58
65
  | `format` | `string` | - | Input format (`X` represents character position, rest are separators) |
59
- | `prefixIcon` | `Component<TablerIconProps>` | - | Prefix icon (Tabler Icons component) |
60
66
  | `placeholder` | `string` | - | Placeholder |
61
67
  | `title` | `string` | - | Tooltip title |
62
68
  | `autocomplete` | `JSX.HTMLAutocomplete` | - | HTML autocomplete attribute |
@@ -73,6 +79,9 @@ import { TextInput } from "@simplysm/solid";
73
79
  | `validate` | `(value: string) => string \| undefined` | - | Custom validation function |
74
80
  | `touchMode` | `boolean` | - | Show error only after field loses focus |
75
81
 
82
+ **Sub-components:**
83
+ - `TextInput.Prefix` -- Prefix element (icon, text, etc.) displayed before the input
84
+
76
85
  ---
77
86
 
78
87
  ## NumberInput
@@ -91,6 +100,11 @@ import { NumberInput } from "@simplysm/solid";
91
100
  // Minimum 2 decimal places
92
101
  <NumberInput value={price()} minDigits={2} />
93
102
 
103
+ // With prefix
104
+ <NumberInput value={price()} onValueChange={setPrice}>
105
+ <NumberInput.Prefix>&#8361;</NumberInput.Prefix>
106
+ </NumberInput>
107
+
94
108
  // With validation
95
109
  <NumberInput required min={0} max={100} value={score()} onValueChange={setScore} />
96
110
  ```
@@ -101,7 +115,6 @@ import { NumberInput } from "@simplysm/solid";
101
115
  | `onValueChange` | `(value: number \| undefined) => void` | - | Value change callback |
102
116
  | `comma` | `boolean` | `true` | Show thousand separators |
103
117
  | `minDigits` | `number` | - | Minimum decimal places |
104
- | `prefixIcon` | `Component<TablerIconProps>` | - | Prefix icon (Tabler Icons component) |
105
118
  | `placeholder` | `string` | - | Placeholder |
106
119
  | `title` | `string` | - | Tooltip title |
107
120
  | `disabled` | `boolean` | - | Disabled state |
@@ -116,6 +129,9 @@ import { NumberInput } from "@simplysm/solid";
116
129
  | `validate` | `(value: number \| undefined) => string \| undefined` | - | Custom validation function |
117
130
  | `touchMode` | `boolean` | - | Show error only after field loses focus |
118
131
 
132
+ **Sub-components:**
133
+ - `NumberInput.Prefix` -- Prefix element (currency symbol, icon, etc.) displayed before the input
134
+
119
135
  ---
120
136
 
121
137
  ## DatePicker
@@ -296,7 +312,6 @@ const [to, setTo] = createSignal<DateOnly>();
296
312
  | `required` | `boolean` | - | Required field |
297
313
  | `disabled` | `boolean` | - | Disabled state |
298
314
  | `size` | `"xs" \| "sm" \| "lg" \| "xl"` | - | Size |
299
- | `periodLabels` | `Partial<Record<DateRangePeriodType, string>>` | `{ day: "일", month: "월", range: "범위" }` | Period type labels (Korean by default) |
300
315
  | `class` | `string` | - | Additional CSS class |
301
316
  | `style` | `JSX.CSSProperties` | - | Inline style |
302
317
 
package/docs/helpers.md CHANGED
@@ -12,45 +12,6 @@ const style = mergeStyles("color: red", { fontSize: "14px" }, props.style);
12
12
 
13
13
  ---
14
14
 
15
- ## splitSlots
16
-
17
- Utility for splitting resolved children into named slots based on `data-*` attributes. Returns a tuple of `[slots accessor, content accessor]`.
18
-
19
- Slot keys are matched against the element's `dataset` (camelCase). Sub-components that set a matching data attribute (e.g., `data-select-header`) are separated into the named slot; all other children remain in `content`.
20
-
21
- ```typescript
22
- import { splitSlots } from "@simplysm/solid";
23
- import { children } from "solid-js";
24
-
25
- // Inside a component:
26
- const resolved = children(() => props.children);
27
- const [slots, content] = splitSlots(resolved, ["selectHeader", "selectAction"] as const);
28
-
29
- // Access inside JSX (within a reactive context)
30
- <div>{slots().selectHeader}</div> // HTMLElement[] matching data-select-header
31
- <div>{content()}</div> // JSX.Element[] – remaining children
32
- ```
33
-
34
- **Signature:**
35
-
36
- ```typescript
37
- function splitSlots<K extends string>(
38
- resolved: { toArray: () => unknown[] },
39
- keys: readonly K[],
40
- ): [Accessor<Record<K, HTMLElement[]>>, Accessor<JSX.Element[]>]
41
- ```
42
-
43
- | Parameter | Type | Description |
44
- |-----------|------|-------------|
45
- | `resolved` | `{ toArray: () => unknown[] }` | Resolved children from SolidJS `children()` |
46
- | `keys` | `readonly K[]` | Slot key names (camelCase, matched against `element.dataset`) |
47
-
48
- Returns a tuple:
49
- - `slots` — `Accessor<Record<K, HTMLElement[]>>` — named slot elements per key
50
- - `content` — `Accessor<JSX.Element[]>` — remaining children not matched by any key
51
-
52
- ---
53
-
54
15
  ## ripple directive
55
16
 
56
17
  Material Design ripple effect directive. Displays ripple effect on click.
package/docs/hooks.md CHANGED
@@ -163,6 +163,45 @@ setValue((prev) => prev + "!");
163
163
 
164
164
  ---
165
165
 
166
+ ## createControllableStore
167
+
168
+ Store hook that automatically handles Controlled/Uncontrolled patterns for objects and arrays. Similar to `createControllableSignal` but uses SolidJS `createStore` internally, supporting path-based updates via `SetStoreFunction`.
169
+
170
+ Operates in controlled mode when `onChange` is provided (setter calls `onChange` with cloned value), uncontrolled mode otherwise (internal store only).
171
+
172
+ ```tsx
173
+ import { createControllableStore } from "@simplysm/solid";
174
+
175
+ // Controlled mode (parent manages state)
176
+ const [items, setItems] = createControllableStore<Item[]>({
177
+ value: () => props.items ?? [],
178
+ onChange: () => props.onItemsChange,
179
+ });
180
+
181
+ // Uncontrolled mode (internal state only)
182
+ const [items, setItems] = createControllableStore<Item[]>({
183
+ value: () => [],
184
+ onChange: () => undefined,
185
+ });
186
+
187
+ // Supports all SetStoreFunction overloads
188
+ setItems(0, "name", "updated"); // path-based update
189
+ setItems(produce((draft) => { ... })); // produce
190
+ setItems(reconcile(newItems)); // reconcile
191
+ ```
192
+
193
+ | Option | Type | Description |
194
+ |--------|------|-------------|
195
+ | `value` | `() => TValue` | Reactive value accessor (syncs external changes to internal store) |
196
+ | `onChange` | `() => ((value: TValue) => void) \| undefined` | Change callback accessor. When defined, enables controlled mode |
197
+
198
+ | Return | Type | Description |
199
+ |--------|------|-------------|
200
+ | `[0]` | `TValue` | Store (reactive proxy) |
201
+ | `[1]` | `SetStoreFunction<TValue>` | Store setter (triggers `onChange` in controlled mode) |
202
+
203
+ ---
204
+
166
205
  ## createMountTransition
167
206
 
168
207
  Mount transition hook for open/close CSS animations. Control DOM rendering with `mounted()` and toggle CSS classes with `animating()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/solid",
3
- "version": "13.0.55",
3
+ "version": "13.0.56",
4
4
  "description": "심플리즘 패키지 - SolidJS 라이브러리",
5
5
  "author": "김석래",
6
6
  "license": "Apache-2.0",
@@ -23,6 +23,7 @@
23
23
  "*.css"
24
24
  ],
25
25
  "dependencies": {
26
+ "@solid-primitives/event-listener": "^2.4.4",
26
27
  "@solid-primitives/media": "^2.3.3",
27
28
  "@solid-primitives/resize-observer": "^2.1.3",
28
29
  "@solid-primitives/storage": "^4.3.3",
@@ -49,8 +50,8 @@
49
50
  "solid-tiptap": "^0.8.0",
50
51
  "tailwind-merge": "^3.5.0",
51
52
  "tailwindcss": "^3.4.19",
52
- "@simplysm/core-browser": "13.0.55",
53
- "@simplysm/core-common": "13.0.55"
53
+ "@simplysm/core-browser": "13.0.56",
54
+ "@simplysm/core-common": "13.0.56"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@solidjs/testing-library": "^0.8.10"
@@ -0,0 +1,346 @@
1
+ import {
2
+ children,
3
+ createMemo,
4
+ createSignal,
5
+ type JSX,
6
+ onMount,
7
+ Show,
8
+ splitProps,
9
+ useContext,
10
+ } from "solid-js";
11
+ import { reconcile, unwrap } from "solid-js/store";
12
+ import { createControllableStore } from "../../../hooks/createControllableStore";
13
+ import { objClone, objEqual } from "@simplysm/core-common";
14
+ import { BusyContainer } from "../../feedback/busy/BusyContainer";
15
+ import { useNotification } from "../../feedback/notification/NotificationContext";
16
+ import { Button } from "../../form-control/Button";
17
+ import { Icon } from "../../display/Icon";
18
+ import { TopbarContext, createTopbarActions } from "../../layout/topbar/TopbarContext";
19
+ import { useDialogInstance } from "../../disclosure/DialogInstanceContext";
20
+ import { Dialog } from "../../disclosure/Dialog";
21
+ import { createEventListener } from "@solid-primitives/event-listener";
22
+ import clsx from "clsx";
23
+ import { IconDeviceFloppy, IconRefresh, IconTrash, IconTrashOff } from "@tabler/icons-solidjs";
24
+ import { isCrudDetailToolsDef, CrudDetailTools } from "./CrudDetailTools";
25
+ import { isCrudDetailBeforeDef, CrudDetailBefore } from "./CrudDetailBefore";
26
+ import { isCrudDetailAfterDef, CrudDetailAfter } from "./CrudDetailAfter";
27
+ import type {
28
+ CrudDetailBeforeDef,
29
+ CrudDetailAfterDef,
30
+ CrudDetailContext,
31
+ CrudDetailInfo,
32
+ CrudDetailProps,
33
+ CrudDetailToolsDef,
34
+ } from "./types";
35
+
36
+ interface CrudDetailComponent {
37
+ <TData extends object>(props: CrudDetailProps<TData>): JSX.Element;
38
+ Tools: typeof CrudDetailTools;
39
+ Before: typeof CrudDetailBefore;
40
+ After: typeof CrudDetailAfter;
41
+ }
42
+
43
+ const CrudDetailBase = <TData extends object>(props: CrudDetailProps<TData>) => {
44
+ const [local, _rest] = splitProps(props, [
45
+ "load",
46
+ "children",
47
+ "submit",
48
+ "toggleDelete",
49
+ "editable",
50
+ "deletable",
51
+ "data",
52
+ "onDataChange",
53
+ "class",
54
+ ]);
55
+
56
+ const noti = useNotification();
57
+ const topbarCtx = useContext(TopbarContext);
58
+ const dialogInstance = useDialogInstance<boolean>();
59
+
60
+ const isModal = dialogInstance !== undefined;
61
+
62
+ const canEdit = () => local.editable ?? true;
63
+
64
+ // -- State --
65
+ const [data, setData] = createControllableStore<TData>({
66
+ value: () => local.data ?? ({} as TData),
67
+ onChange: () => local.onDataChange,
68
+ });
69
+ let originalData: TData | undefined;
70
+
71
+ const [info, setInfo] = createSignal<CrudDetailInfo>();
72
+ const [busyCount, setBusyCount] = createSignal(0);
73
+ const [ready, setReady] = createSignal(false);
74
+
75
+ let formRef: HTMLFormElement | undefined;
76
+
77
+ // -- Load --
78
+ async function doLoad() {
79
+ setBusyCount((c) => c + 1);
80
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 호출
81
+ await noti.try(async () => {
82
+ const result = await local.load();
83
+ setData(reconcile(result.data) as any);
84
+ originalData = objClone(result.data);
85
+ setInfo(result.info);
86
+ }, "조회 실패");
87
+ setBusyCount((c) => c - 1);
88
+ setReady(true);
89
+ }
90
+
91
+ onMount(() => {
92
+ void doLoad();
93
+ });
94
+
95
+ // -- Change Detection --
96
+ function hasChanges(): boolean {
97
+ if (originalData == null) return false;
98
+ return !objEqual(unwrap(data) as unknown, originalData as unknown);
99
+ }
100
+
101
+ // -- Refresh --
102
+ async function handleRefresh() {
103
+ if (hasChanges()) {
104
+ if (!confirm("변경사항을 무시하시겠습니까?")) return;
105
+ }
106
+ await doLoad();
107
+ }
108
+
109
+ // -- Save --
110
+ async function handleSave() {
111
+ if (busyCount() > 0) return;
112
+ if (!local.submit) return;
113
+
114
+ const currentInfo = info();
115
+ if (currentInfo && !currentInfo.isNew && !hasChanges()) {
116
+ noti.info("안내", "변경사항이 없습니다.");
117
+ return;
118
+ }
119
+
120
+ setBusyCount((c) => c + 1);
121
+ // eslint-disable-next-line solid/reactivity -- noti.try 내부에서 비동기 호출
122
+ await noti.try(async () => {
123
+ const result = await local.submit!(objClone(unwrap(data)));
124
+ if (result) {
125
+ noti.success("저장 완료", "저장되었습니다.");
126
+ if (dialogInstance) {
127
+ dialogInstance.close(true);
128
+ } else {
129
+ await doLoad();
130
+ }
131
+ }
132
+ }, "저장 실패");
133
+ setBusyCount((c) => c - 1);
134
+ }
135
+
136
+ async function handleFormSubmit(e: Event) {
137
+ e.preventDefault();
138
+ await handleSave();
139
+ }
140
+
141
+ // -- Toggle Delete --
142
+ async function handleToggleDelete() {
143
+ if (busyCount() > 0) return;
144
+ if (!local.toggleDelete) return;
145
+
146
+ const currentInfo = info();
147
+ if (!currentInfo) return;
148
+
149
+ const del = !currentInfo.isDeleted;
150
+
151
+ setBusyCount((c) => c + 1);
152
+ /* eslint-disable solid/reactivity -- noti.try 내부에서 비동기 호출 */
153
+ await noti.try(
154
+ async () => {
155
+ const result = await local.toggleDelete!(del);
156
+ if (result) {
157
+ noti.success(
158
+ del ? "삭제 완료" : "복구 완료",
159
+ del ? "삭제되었습니다." : "복구되었습니다.",
160
+ );
161
+ if (dialogInstance) {
162
+ dialogInstance.close(true);
163
+ } else {
164
+ await doLoad();
165
+ }
166
+ }
167
+ },
168
+ del ? "삭제 실패" : "복구 실패",
169
+ );
170
+ /* eslint-enable solid/reactivity */
171
+ setBusyCount((c) => c - 1);
172
+ }
173
+
174
+ // -- Keyboard Shortcuts --
175
+ createEventListener(document, "keydown", (e: KeyboardEvent) => {
176
+ if (e.ctrlKey && e.key === "s") {
177
+ e.preventDefault();
178
+ formRef?.requestSubmit();
179
+ }
180
+ if (e.ctrlKey && e.altKey && e.key === "l") {
181
+ e.preventDefault();
182
+ void handleRefresh();
183
+ }
184
+ });
185
+
186
+ // -- Topbar Actions (Page mode) --
187
+ if (topbarCtx) {
188
+ createTopbarActions(() => (
189
+ <>
190
+ <Show when={canEdit() && local.submit}>
191
+ <Button
192
+ size="lg"
193
+ variant="ghost"
194
+ theme="primary"
195
+ onClick={() => formRef?.requestSubmit()}
196
+ >
197
+ <Icon icon={IconDeviceFloppy} class="mr-1" />
198
+ 저장
199
+ </Button>
200
+ </Show>
201
+ <Button size="lg" variant="ghost" theme="info" onClick={() => void handleRefresh()}>
202
+ <Icon icon={IconRefresh} class="mr-1" />
203
+ 새로고침
204
+ </Button>
205
+ </>
206
+ ));
207
+ }
208
+
209
+ // -- Context --
210
+ const ctx: CrudDetailContext<TData> = {
211
+ data,
212
+ setData,
213
+ info: () => info()!,
214
+ busy: () => busyCount() > 0,
215
+ hasChanges,
216
+ save: handleSave,
217
+ refresh: handleRefresh,
218
+ };
219
+
220
+ // -- Children Resolution --
221
+ const rendered = children(() => local.children(ctx));
222
+ const defs = createMemo(() => {
223
+ const arr = rendered.toArray();
224
+ return {
225
+ tools: arr.find(isCrudDetailToolsDef) as CrudDetailToolsDef | undefined,
226
+ before: arr.find(isCrudDetailBeforeDef) as CrudDetailBeforeDef | undefined,
227
+ after: arr.find(isCrudDetailAfterDef) as CrudDetailAfterDef | undefined,
228
+ };
229
+ });
230
+
231
+ const formContent = () =>
232
+ rendered
233
+ .toArray()
234
+ .filter(
235
+ (el) =>
236
+ !isCrudDetailToolsDef(el) && !isCrudDetailBeforeDef(el) && !isCrudDetailAfterDef(el),
237
+ );
238
+
239
+ // -- Render --
240
+ return (
241
+ <>
242
+ {/* Modal mode: Dialog.Action (refresh button in header) */}
243
+ <Show when={isModal}>
244
+ <Dialog.Action>
245
+ <button
246
+ class="flex items-center px-2 text-base-400 hover:text-base-600"
247
+ onClick={() => void handleRefresh()}
248
+ >
249
+ <Icon icon={IconRefresh} />
250
+ </button>
251
+ </Dialog.Action>
252
+ </Show>
253
+
254
+ <BusyContainer
255
+ ready={ready()}
256
+ busy={busyCount() > 0}
257
+ class={clsx("flex h-full flex-col", local.class)}
258
+ >
259
+ {/* Toolbar (page/control mode) */}
260
+ <Show when={!isModal && canEdit()}>
261
+ <div class="flex gap-2 p-2 pb-0">
262
+ <Show when={local.submit}>
263
+ <Button
264
+ size="sm"
265
+ theme="primary"
266
+ variant="ghost"
267
+ onClick={() => formRef?.requestSubmit()}
268
+ >
269
+ <Icon icon={IconDeviceFloppy} class="mr-1" />
270
+ 저장
271
+ </Button>
272
+ </Show>
273
+ <Button size="sm" theme="info" variant="ghost" onClick={() => void handleRefresh()}>
274
+ <Icon icon={IconRefresh} class="mr-1" />
275
+ 새로고침
276
+ </Button>
277
+ <Show
278
+ when={local.toggleDelete && info() && !info()!.isNew && (local.deletable ?? true)}
279
+ >
280
+ {(_) => (
281
+ <Button
282
+ size="sm"
283
+ theme="danger"
284
+ variant="ghost"
285
+ onClick={() => void handleToggleDelete()}
286
+ >
287
+ <Icon icon={info()!.isDeleted ? IconTrashOff : IconTrash} class="mr-1" />
288
+ {info()!.isDeleted ? "복구" : "삭제"}
289
+ </Button>
290
+ )}
291
+ </Show>
292
+ <Show when={defs().tools}>{(toolsDef) => toolsDef().children}</Show>
293
+ </div>
294
+ </Show>
295
+
296
+ {/* Before (outside form) */}
297
+ <Show when={defs().before}>{(beforeDef) => beforeDef().children}</Show>
298
+
299
+ {/* Form */}
300
+ <form ref={formRef} class="flex-1 overflow-auto p-2" onSubmit={handleFormSubmit}>
301
+ {formContent()}
302
+ </form>
303
+
304
+ {/* Last modified info */}
305
+ <Show when={info()?.lastModifiedAt}>
306
+ {(_) => (
307
+ <div class="px-2 pb-1 text-xs text-base-400">
308
+ 최종 수정: {info()!.lastModifiedAt!.toFormatString("yyyy-MM-dd HH:mm")}
309
+ <Show when={info()?.lastModifiedBy}> ({info()!.lastModifiedBy})</Show>
310
+ </div>
311
+ )}
312
+ </Show>
313
+
314
+ {/* After (outside form) */}
315
+ <Show when={defs().after}>{(afterDef) => afterDef().children}</Show>
316
+
317
+ {/* Modal mode: bottom bar */}
318
+ <Show when={isModal && canEdit()}>
319
+ <div class="flex gap-2 border-t border-base-200 p-2">
320
+ <div class="flex-1" />
321
+ <Show
322
+ when={local.toggleDelete && info() && !info()!.isNew && (local.deletable ?? true)}
323
+ >
324
+ {(_) => (
325
+ <Button size="sm" theme="danger" onClick={() => void handleToggleDelete()}>
326
+ <Icon icon={info()!.isDeleted ? IconTrashOff : IconTrash} class="mr-1" />
327
+ {info()!.isDeleted ? "복구" : "삭제"}
328
+ </Button>
329
+ )}
330
+ </Show>
331
+ <Show when={local.submit}>
332
+ <Button size="sm" theme="primary" onClick={() => formRef?.requestSubmit()}>
333
+ 확인
334
+ </Button>
335
+ </Show>
336
+ </div>
337
+ </Show>
338
+ </BusyContainer>
339
+ </>
340
+ );
341
+ };
342
+
343
+ export const CrudDetail = CrudDetailBase as unknown as CrudDetailComponent;
344
+ CrudDetail.Tools = CrudDetailTools;
345
+ CrudDetail.Before = CrudDetailBefore;
346
+ CrudDetail.After = CrudDetailAfter;
@@ -0,0 +1,19 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudDetailAfterDef } from "./types";
3
+
4
+ export function isCrudDetailAfterDef(value: unknown): value is CrudDetailAfterDef {
5
+ return (
6
+ value != null &&
7
+ typeof value === "object" &&
8
+ (value as Record<string, unknown>)["__type"] === "crud-detail-after"
9
+ );
10
+ }
11
+
12
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
13
+ export function CrudDetailAfter(props: { children: JSX.Element }): JSX.Element {
14
+ return {
15
+ __type: "crud-detail-after",
16
+ children: props.children,
17
+ } as unknown as JSX.Element;
18
+ }
19
+ /* eslint-enable solid/reactivity */
@@ -0,0 +1,19 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudDetailBeforeDef } from "./types";
3
+
4
+ export function isCrudDetailBeforeDef(value: unknown): value is CrudDetailBeforeDef {
5
+ return (
6
+ value != null &&
7
+ typeof value === "object" &&
8
+ (value as Record<string, unknown>)["__type"] === "crud-detail-before"
9
+ );
10
+ }
11
+
12
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
13
+ export function CrudDetailBefore(props: { children: JSX.Element }): JSX.Element {
14
+ return {
15
+ __type: "crud-detail-before",
16
+ children: props.children,
17
+ } as unknown as JSX.Element;
18
+ }
19
+ /* eslint-enable solid/reactivity */
@@ -0,0 +1,19 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CrudDetailToolsDef } from "./types";
3
+
4
+ export function isCrudDetailToolsDef(value: unknown): value is CrudDetailToolsDef {
5
+ return (
6
+ value != null &&
7
+ typeof value === "object" &&
8
+ (value as Record<string, unknown>)["__type"] === "crud-detail-tools"
9
+ );
10
+ }
11
+
12
+ /* eslint-disable solid/reactivity -- plain object 반환 패턴으로 reactive context 불필요 */
13
+ export function CrudDetailTools(props: { children: JSX.Element }): JSX.Element {
14
+ return {
15
+ __type: "crud-detail-tools",
16
+ children: props.children,
17
+ } as unknown as JSX.Element;
18
+ }
19
+ /* eslint-enable solid/reactivity */
@@ -0,0 +1,58 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { SetStoreFunction } from "solid-js/store";
3
+ import type { DateTime } from "@simplysm/core-common";
4
+
5
+ // ── Detail Info ──
6
+
7
+ export interface CrudDetailInfo {
8
+ isNew: boolean;
9
+ isDeleted: boolean;
10
+ lastModifiedAt?: DateTime;
11
+ lastModifiedBy?: string;
12
+ }
13
+
14
+ // ── Context ──
15
+
16
+ export interface CrudDetailContext<TData> {
17
+ data: TData;
18
+ setData: SetStoreFunction<TData>;
19
+ info: () => CrudDetailInfo;
20
+ busy: () => boolean;
21
+ hasChanges: () => boolean;
22
+ save: () => Promise<void>;
23
+ refresh: () => Promise<void>;
24
+ }
25
+
26
+ // ── Props ──
27
+
28
+ export interface CrudDetailProps<TData extends object> {
29
+ load: () => Promise<{ data: TData; info: CrudDetailInfo }>;
30
+ children: (ctx: CrudDetailContext<TData>) => JSX.Element;
31
+
32
+ submit?: (data: TData) => Promise<boolean | undefined>;
33
+ toggleDelete?: (del: boolean) => Promise<boolean | undefined>;
34
+ editable?: boolean;
35
+ deletable?: boolean;
36
+
37
+ data?: TData;
38
+ onDataChange?: (data: TData) => void;
39
+
40
+ class?: string;
41
+ }
42
+
43
+ // ── Sub-component Defs ──
44
+
45
+ export interface CrudDetailToolsDef {
46
+ __type: "crud-detail-tools";
47
+ children: JSX.Element;
48
+ }
49
+
50
+ export interface CrudDetailBeforeDef {
51
+ __type: "crud-detail-before";
52
+ children: JSX.Element;
53
+ }
54
+
55
+ export interface CrudDetailAfterDef {
56
+ __type: "crud-detail-after";
57
+ children: JSX.Element;
58
+ }