@simplysm/solid 13.0.53 → 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 (222) hide show
  1. package/README.md +6 -2
  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.css +28 -10
  56. package/dist/components/data/sheet/DataSheet.js +1 -1
  57. package/dist/components/data/sheet/DataSheet.js.map +2 -2
  58. package/dist/components/data/sheet/DataSheet.styles.d.ts.map +1 -1
  59. package/dist/components/data/sheet/DataSheet.styles.js +1 -1
  60. package/dist/components/data/sheet/DataSheet.styles.js.map +1 -1
  61. package/dist/components/disclosure/Dialog.d.ts +16 -10
  62. package/dist/components/disclosure/Dialog.d.ts.map +1 -1
  63. package/dist/components/disclosure/Dialog.js +126 -91
  64. package/dist/components/disclosure/Dialog.js.map +2 -2
  65. package/dist/components/disclosure/DialogContext.d.ts +2 -4
  66. package/dist/components/disclosure/DialogContext.d.ts.map +1 -1
  67. package/dist/components/disclosure/DialogContext.js.map +1 -1
  68. package/dist/components/disclosure/DialogProvider.d.ts.map +1 -1
  69. package/dist/components/disclosure/DialogProvider.js +14 -9
  70. package/dist/components/disclosure/DialogProvider.js.map +2 -2
  71. package/dist/components/disclosure/Dropdown.d.ts +46 -22
  72. package/dist/components/disclosure/Dropdown.d.ts.map +1 -1
  73. package/dist/components/disclosure/Dropdown.js +100 -65
  74. package/dist/components/disclosure/Dropdown.js.map +2 -2
  75. package/dist/components/feedback/notification/NotificationBanner.d.ts.map +1 -1
  76. package/dist/components/feedback/notification/NotificationBanner.js +3 -3
  77. package/dist/components/feedback/notification/NotificationBanner.js.map +1 -1
  78. package/dist/components/feedback/notification/NotificationBell.d.ts.map +1 -1
  79. package/dist/components/feedback/notification/NotificationBell.js +84 -84
  80. package/dist/components/feedback/notification/NotificationBell.js.map +2 -2
  81. package/dist/components/form-control/Invalid.js +1 -1
  82. package/dist/components/form-control/combobox/Combobox.d.ts +6 -3
  83. package/dist/components/form-control/combobox/Combobox.d.ts.map +1 -1
  84. package/dist/components/form-control/combobox/Combobox.js +150 -168
  85. package/dist/components/form-control/combobox/Combobox.js.map +2 -2
  86. package/dist/components/form-control/combobox/ComboboxContext.d.ts +3 -0
  87. package/dist/components/form-control/combobox/ComboboxContext.d.ts.map +1 -1
  88. package/dist/components/form-control/combobox/ComboboxContext.js.map +1 -1
  89. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts +0 -2
  90. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts.map +1 -1
  91. package/dist/components/form-control/date-range-picker/DateRangePicker.js +9 -17
  92. package/dist/components/form-control/date-range-picker/DateRangePicker.js.map +2 -2
  93. package/dist/components/form-control/field/DatePicker.d.ts.map +1 -1
  94. package/dist/components/form-control/field/DatePicker.js +3 -2
  95. package/dist/components/form-control/field/DatePicker.js.map +2 -2
  96. package/dist/components/form-control/field/DateTimePicker.d.ts.map +1 -1
  97. package/dist/components/form-control/field/DateTimePicker.js +3 -2
  98. package/dist/components/form-control/field/DateTimePicker.js.map +2 -2
  99. package/dist/components/form-control/field/Field.styles.d.ts.map +1 -1
  100. package/dist/components/form-control/field/Field.styles.js +2 -1
  101. package/dist/components/form-control/field/Field.styles.js.map +1 -1
  102. package/dist/components/form-control/field/NumberInput.d.ts +15 -5
  103. package/dist/components/form-control/field/NumberInput.d.ts.map +1 -1
  104. package/dist/components/form-control/field/NumberInput.js +181 -141
  105. package/dist/components/form-control/field/NumberInput.js.map +2 -2
  106. package/dist/components/form-control/field/TextInput.d.ts +9 -5
  107. package/dist/components/form-control/field/TextInput.d.ts.map +1 -1
  108. package/dist/components/form-control/field/TextInput.js +199 -154
  109. package/dist/components/form-control/field/TextInput.js.map +2 -2
  110. package/dist/components/form-control/field/TimePicker.d.ts.map +1 -1
  111. package/dist/components/form-control/field/TimePicker.js +3 -2
  112. package/dist/components/form-control/field/TimePicker.js.map +2 -2
  113. package/dist/components/form-control/select/Select.d.ts +3 -3
  114. package/dist/components/form-control/select/Select.d.ts.map +1 -1
  115. package/dist/components/form-control/select/Select.js +116 -100
  116. package/dist/components/form-control/select/Select.js.map +2 -2
  117. package/dist/components/form-control/select/SelectContext.d.ts +9 -1
  118. package/dist/components/form-control/select/SelectContext.d.ts.map +1 -1
  119. package/dist/components/form-control/select/SelectContext.js.map +1 -1
  120. package/dist/components/form-control/select/SelectItem.d.ts.map +1 -1
  121. package/dist/components/form-control/select/SelectItem.js +77 -67
  122. package/dist/components/form-control/select/SelectItem.js.map +2 -2
  123. package/dist/components/form-control/state-preset/StatePreset.d.ts.map +1 -1
  124. package/dist/components/form-control/state-preset/StatePreset.js +1 -1
  125. package/dist/components/form-control/state-preset/StatePreset.js.map +1 -1
  126. package/dist/components/layout/topbar/Topbar.d.ts +2 -0
  127. package/dist/components/layout/topbar/Topbar.d.ts.map +1 -1
  128. package/dist/components/layout/topbar/Topbar.js +2 -0
  129. package/dist/components/layout/topbar/Topbar.js.map +2 -2
  130. package/dist/components/layout/topbar/TopbarActions.d.ts +3 -0
  131. package/dist/components/layout/topbar/TopbarActions.d.ts.map +1 -0
  132. package/dist/components/layout/topbar/TopbarActions.js +17 -0
  133. package/dist/components/layout/topbar/TopbarActions.js.map +6 -0
  134. package/dist/components/layout/topbar/TopbarContainer.d.ts +1 -1
  135. package/dist/components/layout/topbar/TopbarContainer.d.ts.map +1 -1
  136. package/dist/components/layout/topbar/TopbarContainer.js +21 -12
  137. package/dist/components/layout/topbar/TopbarContainer.js.map +2 -2
  138. package/dist/components/layout/topbar/TopbarContext.d.ts +9 -0
  139. package/dist/components/layout/topbar/TopbarContext.d.ts.map +1 -0
  140. package/dist/components/layout/topbar/TopbarContext.js +29 -0
  141. package/dist/components/layout/topbar/TopbarContext.js.map +6 -0
  142. package/dist/components/layout/topbar/TopbarMenu.d.ts.map +1 -1
  143. package/dist/components/layout/topbar/TopbarMenu.js +63 -57
  144. package/dist/components/layout/topbar/TopbarMenu.js.map +2 -2
  145. package/dist/components/layout/topbar/TopbarUser.d.ts.map +1 -1
  146. package/dist/components/layout/topbar/TopbarUser.js +53 -54
  147. package/dist/components/layout/topbar/TopbarUser.js.map +2 -2
  148. package/dist/hooks/createControllableStore.d.ts +29 -0
  149. package/dist/hooks/createControllableStore.d.ts.map +1 -0
  150. package/dist/hooks/createControllableStore.js +19 -0
  151. package/dist/hooks/createControllableStore.js.map +6 -0
  152. package/dist/index.d.ts +6 -1
  153. package/dist/index.d.ts.map +1 -1
  154. package/dist/index.js +7 -2
  155. package/dist/index.js.map +1 -1
  156. package/dist/styles/patterns.styles.d.ts.map +1 -1
  157. package/dist/styles/patterns.styles.js +7 -1
  158. package/dist/styles/patterns.styles.js.map +1 -1
  159. package/docs/data-components.md +428 -0
  160. package/docs/disclosure.md +65 -35
  161. package/docs/form-controls.md +18 -3
  162. package/docs/helpers.md +0 -39
  163. package/docs/hooks.md +39 -0
  164. package/docs/layout.md +70 -1
  165. package/package.json +4 -3
  166. package/src/components/data/crud-detail/CrudDetail.tsx +346 -0
  167. package/src/components/data/crud-detail/CrudDetailAfter.tsx +19 -0
  168. package/src/components/data/crud-detail/CrudDetailBefore.tsx +19 -0
  169. package/src/components/data/crud-detail/CrudDetailTools.tsx +19 -0
  170. package/src/components/data/crud-detail/types.ts +58 -0
  171. package/src/components/data/crud-sheet/CrudSheet.tsx +628 -0
  172. package/src/components/data/crud-sheet/CrudSheetColumn.tsx +34 -0
  173. package/src/components/data/crud-sheet/CrudSheetFilter.tsx +21 -0
  174. package/src/components/data/crud-sheet/CrudSheetHeader.tsx +19 -0
  175. package/src/components/data/crud-sheet/CrudSheetTools.tsx +21 -0
  176. package/src/components/data/crud-sheet/types.ts +133 -0
  177. package/src/components/data/kanban/Kanban.tsx +72 -65
  178. package/src/components/data/kanban/KanbanContext.ts +7 -1
  179. package/src/components/data/list/ListItem.tsx +31 -18
  180. package/src/components/data/sheet/DataSheet.css +28 -10
  181. package/src/components/data/sheet/DataSheet.styles.ts +1 -1
  182. package/src/components/data/sheet/DataSheet.tsx +1 -1
  183. package/src/components/disclosure/Dialog.tsx +143 -105
  184. package/src/components/disclosure/DialogContext.ts +2 -4
  185. package/src/components/disclosure/DialogProvider.tsx +4 -2
  186. package/src/components/disclosure/Dropdown.tsx +174 -86
  187. package/src/components/feedback/notification/NotificationBanner.tsx +3 -9
  188. package/src/components/feedback/notification/NotificationBell.tsx +51 -57
  189. package/src/components/form-control/Invalid.tsx +1 -1
  190. package/src/components/form-control/combobox/Combobox.tsx +109 -133
  191. package/src/components/form-control/combobox/ComboboxContext.ts +4 -1
  192. package/src/components/form-control/date-range-picker/DateRangePicker.tsx +6 -16
  193. package/src/components/form-control/field/DatePicker.tsx +4 -1
  194. package/src/components/form-control/field/DateTimePicker.tsx +3 -0
  195. package/src/components/form-control/field/Field.styles.ts +1 -0
  196. package/src/components/form-control/field/NumberInput.tsx +131 -86
  197. package/src/components/form-control/field/TextInput.tsx +139 -88
  198. package/src/components/form-control/field/TimePicker.tsx +3 -0
  199. package/src/components/form-control/select/Select.tsx +85 -67
  200. package/src/components/form-control/select/SelectContext.ts +12 -1
  201. package/src/components/form-control/select/SelectItem.tsx +39 -18
  202. package/src/components/form-control/state-preset/StatePreset.tsx +1 -0
  203. package/src/components/layout/topbar/Topbar.tsx +3 -0
  204. package/src/components/layout/topbar/TopbarActions.tsx +8 -0
  205. package/src/components/layout/topbar/TopbarContainer.tsx +9 -5
  206. package/src/components/layout/topbar/TopbarContext.ts +36 -0
  207. package/src/components/layout/topbar/TopbarMenu.tsx +52 -55
  208. package/src/components/layout/topbar/TopbarUser.tsx +28 -31
  209. package/src/hooks/createControllableStore.ts +47 -0
  210. package/src/index.ts +6 -1
  211. package/src/styles/patterns.styles.ts +7 -1
  212. package/tailwind.css +4 -0
  213. package/dist/helpers/splitSlots.d.ts +0 -25
  214. package/dist/helpers/splitSlots.d.ts.map +0 -1
  215. package/dist/helpers/splitSlots.js +0 -25
  216. package/dist/helpers/splitSlots.js.map +0 -6
  217. package/dist/hooks/createItemTemplate.d.ts +0 -17
  218. package/dist/hooks/createItemTemplate.d.ts.map +0 -1
  219. package/dist/hooks/createItemTemplate.js +0 -40
  220. package/dist/hooks/createItemTemplate.js.map +0 -6
  221. package/src/helpers/splitSlots.ts +0 -51
  222. package/src/hooks/createItemTemplate.tsx +0 -42
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/docs/layout.md CHANGED
@@ -127,10 +127,79 @@ const userMenus: TopbarUserMenu[] = [
127
127
  ```
128
128
 
129
129
  **Sub-components:**
130
- - `Topbar.Container` -- Container wrapping main content below topbar
130
+ - `Topbar.Container` -- Container wrapping topbar and main content, provides `TopbarContext`
131
+ - `Topbar.Actions` -- Slot outlet that renders actions registered via `createTopbarActions`
131
132
  - `Topbar.Menu` -- Menu items list
132
133
  - `Topbar.User` -- User menu (dropdown)
133
134
 
135
+ ### Topbar Actions Slot
136
+
137
+ A slot pattern that lets child pages inject action buttons (save, delete, etc.) into the topbar. The parent layout defines **where** actions appear, child pages define **what** to show.
138
+
139
+ **Layout (define the slot):**
140
+
141
+ ```tsx
142
+ import { Topbar } from "@simplysm/solid";
143
+
144
+ <Topbar.Container>
145
+ <Topbar>
146
+ <span>Title</span>
147
+ <Topbar.Actions />
148
+ <div class="flex-1" />
149
+ </Topbar>
150
+ <main class="flex-1 overflow-auto p-4">
151
+ {props.children}
152
+ </main>
153
+ </Topbar.Container>
154
+ ```
155
+
156
+ **Child page (fill the slot):**
157
+
158
+ ```tsx
159
+ import { createTopbarActions, Button } from "@simplysm/solid";
160
+
161
+ function UserPage() {
162
+ createTopbarActions(() => (
163
+ <>
164
+ <Button theme="primary">Save</Button>
165
+ <Button>Cancel</Button>
166
+ </>
167
+ ));
168
+
169
+ return <div>...</div>;
170
+ }
171
+ ```
172
+
173
+ When `UserPage` mounts, the buttons appear in the topbar. When it unmounts, the actions are automatically cleaned up via `onCleanup`.
174
+
175
+ **createTopbarActions(accessor: () => JSX.Element): void**
176
+
177
+ Registers actions in the nearest `Topbar.Container` scope. Automatically removes actions on component unmount. Must be called inside `Topbar.Container`.
178
+
179
+ **useTopbarActionsAccessor(): Accessor&lt;JSX.Element | undefined&gt;**
180
+
181
+ Returns the actions accessor directly. For advanced use cases such as building custom topbar components. Must be called inside `Topbar.Container`.
182
+
183
+ ```tsx
184
+ import { useTopbarActionsAccessor } from "@simplysm/solid";
185
+
186
+ const actions = useTopbarActionsAccessor();
187
+ // actions() returns the currently registered JSX.Element or undefined
188
+ ```
189
+
190
+ **TopbarContext:**
191
+
192
+ `TopbarContext` is exported for building custom topbar layouts. `Topbar.Container` wraps children with `TopbarContext.Provider`, sharing `actions` accessor and `setActions` setter.
193
+
194
+ ```typescript
195
+ import { TopbarContext } from "@simplysm/solid";
196
+
197
+ interface TopbarContextValue {
198
+ actions: Accessor<JSX.Element | undefined>;
199
+ setActions: Setter<JSX.Element | undefined>;
200
+ }
201
+ ```
202
+
134
203
  **TopbarMenuItem type:**
135
204
 
136
205
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/solid",
3
- "version": "13.0.53",
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.53",
53
- "@simplysm/core-common": "13.0.53"
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
+ }