pejay-ui 1.4.2 → 1.5.0

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 (148) hide show
  1. package/README.md +26 -0
  2. package/bin/cli.js +45 -15
  3. package/package.json +77 -54
  4. package/registry/buttons.json +3 -2
  5. package/registry/dropdowns.json +3 -1
  6. package/registry/forms.json +51 -23
  7. package/registry/hotkeys.json +12 -0
  8. package/registry/overlays.json +18 -2
  9. package/registry/panels.json +21 -0
  10. package/registry/skeleton.json +20 -0
  11. package/registry/spinner.json +13 -0
  12. package/templates/button/Button.tsx +8 -7
  13. package/templates/button/README.md +81 -0
  14. package/templates/button/index.ts +1 -2
  15. package/templates/form/{checkbox-group.tsx → choices/checkbox-group.tsx} +1 -1
  16. package/templates/form/{checkbox.tsx → choices/checkbox.tsx} +1 -1
  17. package/templates/form/{radio-group.tsx → choices/radio-group.tsx} +1 -1
  18. package/templates/form/{radio.tsx → choices/radio.tsx} +1 -1
  19. package/templates/form/choices/readme.checkbox-group.md +27 -0
  20. package/templates/form/choices/readme.checkbox.md +26 -0
  21. package/templates/form/choices/readme.radio-group.md +26 -0
  22. package/templates/form/choices/readme.radio.md +24 -0
  23. package/templates/form/choices/readme.switch.md +26 -0
  24. package/templates/form/{switch.tsx → choices/switch.tsx} +1 -1
  25. package/templates/form/{file-input.tsx → file/file-input.tsx} +2 -2
  26. package/templates/form/file/readme.file-input.md +26 -0
  27. package/templates/form/index.ts +19 -22
  28. package/templates/form/{amount-input.tsx → numeric/amount-input.tsx} +2 -2
  29. package/templates/form/{number-input.tsx → numeric/number-input.tsx} +2 -2
  30. package/templates/form/{range-slider.tsx → numeric/range-slider.tsx} +1 -1
  31. package/templates/form/numeric/readme.amount-input.md +27 -0
  32. package/templates/form/numeric/readme.number-input.md +26 -0
  33. package/templates/form/numeric/readme.range-slider.md +27 -0
  34. package/templates/form/{date-picker.tsx → pickers/date-picker.tsx} +2 -2
  35. package/templates/form/{date-range-picker.tsx → pickers/date-range-picker.tsx} +2 -2
  36. package/templates/form/pickers/readme.date-picker.md +26 -0
  37. package/templates/form/pickers/readme.date-range-picker.md +25 -0
  38. package/templates/form/pickers/readme.time-picker.md +25 -0
  39. package/templates/form/pickers/readme.time-range-picker.md +25 -0
  40. package/templates/form/{time-picker.tsx → pickers/time-picker.tsx} +1 -1
  41. package/templates/form/{time-range-picker.tsx → pickers/time-range-picker.tsx} +1 -1
  42. package/templates/form/{input.tsx → text-inputs/input.tsx} +1 -1
  43. package/templates/form/{password-input.tsx → text-inputs/password-input.tsx} +1 -1
  44. package/templates/form/text-inputs/readme.email-input.md +24 -0
  45. package/templates/form/text-inputs/readme.input.md +28 -0
  46. package/templates/form/text-inputs/readme.password-input.md +24 -0
  47. package/templates/form/text-inputs/readme.phone-input.md +24 -0
  48. package/templates/form/text-inputs/readme.textarea.md +24 -0
  49. package/templates/form/text-inputs/readme.url-input.md +23 -0
  50. package/templates/form/{textarea.tsx → text-inputs/textarea.tsx} +1 -1
  51. package/templates/hotkeys/README.md +134 -0
  52. package/templates/hotkeys/components/HotkeyProvider.tsx +78 -0
  53. package/templates/hotkeys/components/HotkeysHelpModal.tsx +102 -0
  54. package/templates/hotkeys/core/key-matcher.ts +106 -0
  55. package/templates/hotkeys/core/registry.ts +39 -0
  56. package/templates/hotkeys/core/types.ts +15 -0
  57. package/templates/hotkeys/hooks/useHotkey.ts +43 -0
  58. package/templates/hotkeys/index.ts +6 -0
  59. package/templates/layouts/lv1/app-layout.tsx +1 -1
  60. package/templates/layouts/lv1/sidebar-menu.tsx +1 -1
  61. package/templates/notes/app-provider/app-provider-side-panel-modals-roadmap.md +606 -0
  62. package/templates/notes/app-provider/manual-open-close-side-panel-and-modal.md +913 -0
  63. package/templates/notes/app-provider/side-panel-card-hooks-and-complexity.md +578 -0
  64. package/templates/notes/under-dev/AppProvider.tsx +92 -0
  65. package/templates/notes/under-dev/app-context.ts +14 -0
  66. package/templates/notes/under-dev/card/base-card.tsx +35 -0
  67. package/templates/notes/under-dev/card/index.ts +4 -0
  68. package/templates/notes/under-dev/card/modal-card.tsx +88 -0
  69. package/templates/notes/under-dev/card/side-panel-card.tsx +127 -0
  70. package/templates/notes/under-dev/form-overlay-registry.ts +42 -0
  71. package/templates/notes/under-dev/keyboard-shortcuts-help.tsx +79 -0
  72. package/templates/notes/under-dev/keyboard-utils.ts +22 -0
  73. package/templates/notes/under-dev/overlay/backdrop.tsx +95 -0
  74. package/templates/notes/under-dev/overlay/index.ts +4 -0
  75. package/templates/notes/under-dev/overlay/modal.tsx +43 -0
  76. package/templates/notes/under-dev/overlay/side-panel.tsx +126 -0
  77. package/templates/notes/under-dev/overlay-close.ts +50 -0
  78. package/templates/notes/under-dev/page-shortcut-registry.ts +9 -0
  79. package/templates/notes/under-dev/unsaved-changes-notify.ts +11 -0
  80. package/templates/notes/under-dev/use-keyboard-shortcuts.tsx +110 -0
  81. package/templates/notes/under-dev/useFormDirty.ts +6 -0
  82. package/templates/notes/under-dev/useFormOverlayRegistration.ts +47 -0
  83. package/templates/notes/under-dev/useFormPanel.tsx +18 -0
  84. package/templates/notes/under-dev/useFormTabHandler.ts +22 -0
  85. package/templates/notes/under-dev/useHorizontalWheelScroll.ts +27 -0
  86. package/templates/notes/under-dev/useOverlay.ts +41 -0
  87. package/templates/overlays/index.ts +2 -1
  88. package/templates/overlays/portal/portal.tsx +26 -0
  89. package/templates/overlays/tooltip/readme.tooltip.md +26 -0
  90. package/templates/{button → overlays/tooltip}/tooltip.tsx +1 -1
  91. package/templates/panels/COMPONENTS.md +103 -0
  92. package/templates/panels/README.md +702 -0
  93. package/templates/panels/components/base-card.tsx +33 -0
  94. package/templates/panels/components/index.ts +8 -0
  95. package/templates/panels/components/modal/backdrop.tsx +88 -0
  96. package/templates/panels/components/modal/modal-card.tsx +139 -0
  97. package/templates/panels/components/modal/modal-raw.tsx +36 -0
  98. package/templates/panels/components/modal/modal.tsx +49 -0
  99. package/templates/panels/components/side-panel/side-panel-card.tsx +123 -0
  100. package/templates/panels/components/side-panel/side-panel-raw.tsx +25 -0
  101. package/templates/panels/components/side-panel/side-panel.tsx +135 -0
  102. package/templates/panels/core/PanelProvider.tsx +145 -0
  103. package/templates/panels/core/constants.ts +9 -0
  104. package/templates/panels/core/form-overlay-registry.ts +35 -0
  105. package/templates/panels/core/index.ts +6 -0
  106. package/templates/panels/core/overlay-close.ts +11 -0
  107. package/templates/panels/core/panel-context.ts +41 -0
  108. package/templates/panels/core/types.ts +41 -0
  109. package/templates/panels/hooks/index.ts +7 -0
  110. package/templates/panels/hooks/useFormDirty.ts +6 -0
  111. package/templates/panels/hooks/useFormOverlayRegistration.ts +92 -0
  112. package/templates/panels/hooks/useFormPanel.tsx +18 -0
  113. package/templates/panels/hooks/useFormTabHandler.ts +25 -0
  114. package/templates/panels/hooks/useHorizontalWheelScroll.ts +31 -0
  115. package/templates/panels/hooks/useModalForm.tsx +22 -0
  116. package/templates/panels/hooks/useOverlay.ts +65 -0
  117. package/templates/panels/index.ts +3 -0
  118. package/templates/panels/vendor-example/by-using-modal/VendorModalPage.tsx +47 -0
  119. package/templates/panels/vendor-example/by-using-modal/useVendorModalForm.tsx +19 -0
  120. package/templates/panels/vendor-example/by-using-modal/vendor-modal-form.tsx +112 -0
  121. package/templates/panels/vendor-example/by-using-modal/vendor-types.ts +29 -0
  122. package/templates/panels/vendor-example/by-using-sidepanel/VendorsPage.tsx +47 -0
  123. package/templates/panels/vendor-example/by-using-sidepanel/useVendorFormPanel.tsx +15 -0
  124. package/templates/panels/vendor-example/by-using-sidepanel/vendor-form.tsx +108 -0
  125. package/templates/panels/vendor-example/by-using-sidepanel/vendor-types.ts +29 -0
  126. package/templates/select-dropdown/README.md +62 -0
  127. package/templates/select-dropdown/multiselect-input.tsx +2 -2
  128. package/templates/select-dropdown/select-input.tsx +2 -2
  129. package/templates/skeleton/README.md +53 -0
  130. package/templates/skeleton/index.ts +2 -0
  131. package/templates/skeleton/skeleton.css +36 -0
  132. package/templates/skeleton/skeleton.tsx +40 -0
  133. package/templates/skeleton/types.ts +12 -0
  134. package/templates/spinner/README.md +51 -0
  135. package/templates/spinner/index.ts +1 -0
  136. package/templates/spinner/spinner.css +58 -0
  137. package/templates/spinner/spinner.tsx +263 -0
  138. package/templates/toast/container.tsx +2 -2
  139. package/templates/utilities/formater.dateTime.md +74 -0
  140. package/templates/utilities/formater.dateTime.ts +310 -0
  141. package/templates/utilities/formater.phoneNumber.md +32 -0
  142. package/templates/utilities/formater.phoneNumber.ts +143 -0
  143. package/templates/utilities/sanitize.md +23 -0
  144. package/templates/utilities/sanitize.ts +148 -0
  145. /package/templates/form/{email-input.tsx → text-inputs/email-input.tsx} +0 -0
  146. /package/templates/form/{phone-input.tsx → text-inputs/phone-input.tsx} +0 -0
  147. /package/templates/form/{url-input.tsx → text-inputs/url-input.tsx} +0 -0
  148. /package/templates/{overlays → notes/under-dev/overlay}/portal.tsx +0 -0
@@ -0,0 +1,578 @@
1
+ # SidePanelCard, Tabs, Hooks & Why the Layering Exists
2
+
3
+ > Deep dive into `SidePanelCard`, `CustomerTab` / `AdvancedTab`, `useCallback` in form hooks, naming (`openForm` vs `openCustomerForm`), deprecated exports, and whether the hook chain is over-engineered.
4
+
5
+ ---
6
+
7
+ ## 1. SidePanelCard — What It Is and Every Prop
8
+
9
+ **File:** `src/components/base/card/side-panel-card.tsx`
10
+
11
+ `SidePanelCard` is **not** the overlay itself. It is the **white panel UI shell** that slides in from the right. Think of it as the layout frame: header, scrollable body, footer.
12
+
13
+ The actual slide-in animation and backdrop come from `<SidePanel>` (rendered by `AppProvider`). Your form component renders **inside** that shell.
14
+
15
+ ### Visual structure
16
+
17
+ ```
18
+ ┌─────────────────────────────────────┐ ← SidePanel (animation + backdrop)
19
+ │ ┌─────────────────────────────────┐ │
20
+ │ │ SidePanelCard │ │
21
+ │ │ ┌─────────────────────────────┐ │ │
22
+ │ │ │ Header: title + X button │ │ │
23
+ │ │ │ headerSlot (e.g. FormTabs) │ │ │
24
+ │ │ ├─────────────────────────────┤ │ │
25
+ │ │ │ Body (scrollable) │ │ │
26
+ │ │ │ {children} │ │ │ ← CustomerTab or AdvancedTab
27
+ │ │ ├─────────────────────────────┤ │ │
28
+ │ │ │ Footer: Cancel / Save │ │ │
29
+ │ │ └─────────────────────────────┘ │ │
30
+ │ └─────────────────────────────────┘ │
31
+ └─────────────────────────────────────┘
32
+ ```
33
+
34
+ ### All props explained
35
+
36
+ | Prop | Type | Default | Purpose |
37
+ |------|------|---------|---------|
38
+ | `children` | `ReactNode` | — | **The form fields** — tab content goes here |
39
+ | `title` | `string?` | — | Panel heading ("Add Customer", "Edit Customer") |
40
+ | `description` | `string?` | — | Subtitle under the title |
41
+ | `className` | `string?` | — | Extra CSS on the outer panel |
42
+ | `close` | `() => void?` | — | Close handler passed from overlay system |
43
+ | `footer` | `ReactNode?` | — | Bottom bar — usually Cancel + Save buttons |
44
+ | `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"sm"` | Preset panel width |
45
+ | `width` | `string?` | — | Custom CSS width; overrides `size` |
46
+ | `headerSlot` | `ReactNode?` | — | Renders below title row — used for tab switcher |
47
+ | `bodyClassName` | `string?` | — | Extra CSS on the scrollable body |
48
+ | `onSubmit` | `() => void?` | — | Save handler — wired to `Ctrl+Enter` shortcut |
49
+ | `isDirty` | `boolean` | `false` | If true, closing shows "Unsaved changes" dialog |
50
+ | `closeDisabled` | `boolean` | `false` | Blocks X, Escape, backdrop while async work runs |
51
+ | `formTabs` | `FormTabsConfig?` | — | Enables `Alt+←` / `Alt+→` tab keyboard navigation |
52
+
53
+ ### Preset widths (`size`)
54
+
55
+ | Size | Width |
56
+ |------|-------|
57
+ | `sm` | `min(480px, 100vw)` |
58
+ | `md` | `min(560px, 100vw)` |
59
+ | `lg` | `min(720px, 92vw)` ← Customer form uses this |
60
+ | `xl` | `min(840px, 95vw)` |
61
+
62
+ ### What SidePanelCard does automatically (you don't call these yourself)
63
+
64
+ When `close` is provided, it runs `useFormOverlayRegistration()` which registers:
65
+
66
+ - `onSubmit` → global `Ctrl+Enter` triggers save
67
+ - `isDirty` → unsaved-changes guard on close
68
+ - `closeDisabled` → prevents accidental close during save
69
+ - `formTabs` → keyboard tab switching
70
+
71
+ The X button and backdrop both call `requestOverlayCloseWithConfirm()` — not `close()` directly — so dirty forms get a confirmation first.
72
+
73
+ ---
74
+
75
+ ## 1b. How Data / Fields Show Inside SidePanelCard
76
+
77
+ Data does **not** live in `SidePanelCard`. The card is a dumb layout shell. All field state lives in **`CustomerForm`** (the parent), and tabs receive it as props.
78
+
79
+ ### Data flow diagram
80
+
81
+ ```
82
+ CustomerForm (owns all state)
83
+
84
+ ├── useState<CustomerFormValues>(values) ← single source of truth
85
+ ├── setField(key, value) ← updates one field
86
+
87
+ └── SidePanelCard
88
+ └── children:
89
+ activeTab === "customer"
90
+ ? <CustomerTab values={values} setField={setField} />
91
+ : <AdvancedTab values={values} setField={setField} />
92
+ ```
93
+
94
+ ### Where values come from
95
+
96
+ **File:** `src/routes/customers/components/customer-form/index.tsx`
97
+
98
+ ```tsx
99
+ const [values, setValues] = useState<CustomerFormValues>(() => ({
100
+ ...getDefaultCustomerFormValues(), // empty defaults for "create"
101
+ ...(customer ? customerToFormValues(customer) : {}), // pre-fill for "update"
102
+ }));
103
+
104
+ const setField = useCallback((key, value) => {
105
+ setValues((prev) => ({ ...prev, [key]: value }));
106
+ }, []);
107
+ ```
108
+
109
+ - **Create mode:** starts from `getDefaultCustomerFormValues()` (empty name, default country, etc.)
110
+ - **Update mode:** merges `customerToFormValues(customer)` on top of defaults (maps `customer.company` → `customerName`, etc.)
111
+
112
+ ### How a single field renders
113
+
114
+ Every field in `CustomerTab` / `AdvancedTab` follows the same pattern:
115
+
116
+ ```tsx
117
+ <FormField label="Customer Name" required>
118
+ <LightInput
119
+ value={values.customerName} // read from shared state
120
+ onChange={(v) => setField("customerName", v)} // write back via setField
121
+ placeholder="Legal entity name"
122
+ />
123
+ </FormField>
124
+ ```
125
+
126
+ - `values.customerName` — current value from parent state
127
+ - `setField("customerName", v)` — updates parent state, React re-renders tab with new value
128
+ - `FormField` — label wrapper
129
+ - `LightInput` — styled input component
130
+
131
+ **SidePanelCard never touches field data.** It only wraps `{children}` in a scrollable div.
132
+
133
+ ---
134
+
135
+ ## 1c. CustomerTab vs AdvancedTab
136
+
137
+ Both tabs share the same props interface:
138
+
139
+ **File:** `src/routes/customers/components/customer-form/form-controls.tsx`
140
+
141
+ ```tsx
142
+ export type CustomerFormFieldsProps = {
143
+ values: CustomerFormValues; // all form fields as one object
144
+ setField: SetCustomerField; // (key, value) => void
145
+ };
146
+ ```
147
+
148
+ They are **pure presentation components** — no local state, no API calls. They only render inputs bound to `values` / `setField`.
149
+
150
+ ### CustomerTab — "main" entity info
151
+
152
+ **File:** `src/routes/customers/components/customer-form/customer-tab.tsx`
153
+
154
+ | Section | Fields |
155
+ |---------|--------|
156
+ | Basic Information | customerName, customerId, address lines, country, state, city, zip |
157
+ | Billing Address | billing address fields + "Same as mailing" checkbox |
158
+ | Contact Ecosystem | primary/secondary contact, phones, emails |
159
+ | Regulatory & Classification | regulatory type/number, URS #, blacklisted, isBroker |
160
+
161
+ **Special logic in CustomerTab:**
162
+
163
+ - **`sameAsMailing` checkbox** — when checked, billing fields are disabled and mirror mailing address
164
+ - **`setMailingField`** — when user edits mailing address and "same as mailing" is on, billing fields auto-sync
165
+ - **`syncBillingFromMailing`** — copies all mailing → billing in one shot
166
+
167
+ This logic stays in `CustomerTab` because it only affects fields on that tab.
168
+
169
+ ### AdvancedTab — billing, credit, quotes
170
+
171
+ **File:** `src/routes/customers/components/customer-form/advanced-tab.tsx`
172
+
173
+ | Section | Fields |
174
+ |---------|--------|
175
+ | Billing & Credit | currency, creditLimit, paymentTerms, salesRep, factoringCompany, websiteUrl |
176
+ | Invoice Options | showTelFaxOnInvoice, showDetailedRateOnInvoice |
177
+ | Duplicate | duplicateAsShipper, duplicateAsConsignee |
178
+ | Internal Notes | internalNotes (textarea) |
179
+ | Quote Settings | showMilesOnQuote, rateType, fscType, fscRate + $/% toggle |
180
+
181
+ No special sync logic — every field is a simple `setField(key, value)`.
182
+
183
+ ### Why split into two tabs?
184
+
185
+ The customer form has **40+ fields**. Splitting keeps the first tab focused on identity/address/contact (what users fill most often) and moves billing/credit/quote settings to a second tab so the panel isn't one endless scroll.
186
+
187
+ Tab switching is controlled in `CustomerForm`:
188
+
189
+ ```tsx
190
+ const [activeTab, setActiveTab] = useState<CustomerFormTab>("customer");
191
+
192
+ // In SidePanelCard headerSlot:
193
+ <FormTabs tabs={CUSTOMER_FORM_TABS} active={activeTab} onChange={handleTabChange} />
194
+
195
+ // In body:
196
+ {activeTab === "customer" ? <CustomerTab ... /> : <AdvancedTab ... />}
197
+ ```
198
+
199
+ Tab IDs come from `CUSTOMER_FORM_TABS` in `customer-form.ts`: `"customer"` and `"advanced"`.
200
+
201
+ ---
202
+
203
+ ## 2. How `useCallback` Works in `useFormPanel` and `useCustomerFormPanel`
204
+
205
+ ### The code
206
+
207
+ ```tsx
208
+ // useFormPanel.tsx
209
+ export function useFormPanel() {
210
+ const { openSidePanel } = useOverlay();
211
+
212
+ return useCallback(
213
+ (Component, props) => {
214
+ openSidePanel(({ close }) => (
215
+ <Component {...props} close={close} />
216
+ ));
217
+ },
218
+ [openSidePanel],
219
+ );
220
+ }
221
+
222
+ // use-customer-form-panel.tsx
223
+ export function useCustomerFormPanel() {
224
+ const openForm = useFormPanel();
225
+
226
+ return useCallback(
227
+ (mode = "create", customer?) => {
228
+ openForm(CustomerForm, { mode, customer });
229
+ },
230
+ [openForm],
231
+ );
232
+ }
233
+ ```
234
+
235
+ ### What `useCallback` does here (plain English)
236
+
237
+ `useCallback` **memoizes a function** — it returns the **same function reference** on re-renders unless its dependencies change.
238
+
239
+ Without it, every time `CustomersPage` re-renders (search typed, page changed, menu opened), `useCustomerFormPanel()` would return a **brand new function**. That new function would:
240
+
241
+ 1. Break `useCallback` in `handleEdit` / `handleView` (they depend on `openCustomerForm`)
242
+ 2. Re-trigger `usePageShortcut` effect (it depends on the handler)
243
+ 3. Cause unnecessary child re-renders if passed as props
244
+
245
+ ### Dependency chain
246
+
247
+ ```
248
+ useOverlay()
249
+ └── openSidePanel ← stable (useCallback inside useOverlay, deps: [open])
250
+
251
+ useFormPanel()
252
+ └── openForm ← stable while openSidePanel unchanged (deps: [openSidePanel])
253
+
254
+ useCustomerFormPanel()
255
+ └── openCustomerForm ← stable while openForm unchanged (deps: [openForm])
256
+ ```
257
+
258
+ Each layer wraps the inner function and only recreates when the inner one changes. Since `open` from `AppProvider` is stable (`useCallback` with `[]` deps), the whole chain stays stable across page re-renders.
259
+
260
+ ### Step-by-step when user clicks "Add Customer"
261
+
262
+ ```
263
+ 1. CustomersPage re-renders (maybe 50 times while user types in search)
264
+ → openCustomerForm is STILL the same function reference (useCallback)
265
+
266
+ 2. User clicks "Add Customer"
267
+ → openCustomerForm("create") is called
268
+
269
+ 3. Inside useCustomerFormPanel's useCallback:
270
+ → openForm(CustomerForm, { mode: "create" })
271
+
272
+ 4. Inside useFormPanel's useCallback:
273
+ → openSidePanel(({ close }) => <CustomerForm mode="create" close={close} />)
274
+
275
+ 5. Inside useOverlay's useCallback:
276
+ → open("side-panel", content, { onSide: "right" })
277
+
278
+ 6. AppProvider pushes to stack → SidePanel renders
279
+ ```
280
+
281
+ The `useCallback` layers don't affect **when** the panel opens — they affect **stability of the function reference** so other hooks and memoized children don't break.
282
+
283
+ ---
284
+
285
+ ## 3. Naming: `openForm` vs `openCustomerForm`
286
+
287
+ These are **different variables at different layers**. Same pattern, different names for clarity.
288
+
289
+ ### Layer map
290
+
291
+ | Variable name | Where | What it opens |
292
+ |---------------|-------|---------------|
293
+ | `openSidePanel` | `useOverlay()` | Any content — generic |
294
+ | `openForm` | `useFormPanel()` | Any **form component** in a side panel — still generic |
295
+ | `openCustomerForm` | `useCustomerFormPanel()` | Specifically the **Customer** form |
296
+ | `openAgentForm` | `useAgentFormPanel()` | Specifically the **Agent** form |
297
+ | `openCustomerForm` | `CustomersPage` | Same function, renamed at page level for readability |
298
+
299
+ ### Why two names for the same thing?
300
+
301
+ ```tsx
302
+ // Inside useCustomerFormPanel — generic inner name
303
+ const openForm = useFormPanel(); // "openForm" = I can open ANY form component
304
+
305
+ // Inside CustomersPage — domain-specific outer name
306
+ const openCustomerForm = useCustomerFormPanel(); // "openCustomerForm" = this page opens customers
307
+ ```
308
+
309
+ **`openForm`** is an implementation detail inside the hook — it means "the generic form opener from `useFormPanel`".
310
+
311
+ **`openCustomerForm`** is the public API of the hook — when you read `CustomersPage`, you immediately know what opens without looking at imports.
312
+
313
+ Same pattern everywhere:
314
+
315
+ ```tsx
316
+ // agents/index.tsx
317
+ const openAgentForm = useAgentFormPanel();
318
+
319
+ // shippers — likely
320
+ const openShipperForm = useShipperFormPanel();
321
+ ```
322
+
323
+ You **could** name them all `openForm` at the page level, but then every page would have `openForm()` and it would be unclear which entity you're creating.
324
+
325
+ ### What each function signature looks like
326
+
327
+ ```tsx
328
+ // Generic — accepts any component + props
329
+ openForm(Component, props)
330
+
331
+ // Domain-specific — accepts business params only
332
+ openCustomerForm(mode?: "create" | "update", customer?: Customer)
333
+ openAgentForm(mode?, agent?)
334
+ ```
335
+
336
+ The domain hook **hides** the component reference. The page never imports `CustomerForm` directly — it just calls `openCustomerForm("create")`.
337
+
338
+ ---
339
+
340
+ ## 4. Why `useSidePanel` and `useModal` Are Deprecated
341
+
342
+ **File:** `src/hooks/useOverlay.ts`
343
+
344
+ ```tsx
345
+ /** @deprecated Prefer `useOverlay` */
346
+ export function useSidePanel() {
347
+ return useOverlay();
348
+ }
349
+
350
+ /** @deprecated Prefer `useModal` */
351
+ export function useModal() {
352
+ const { openModal, isOverlayOpen, stackDepth } = useOverlay();
353
+ return { openModal, isOverlayOpen, stackDepth };
354
+ }
355
+ ```
356
+
357
+ ### Reason: they were merged into one hook
358
+
359
+ Originally the project likely had **two separate hooks**:
360
+
361
+ - `useSidePanel()` → only `openSidePanel`
362
+ - `useModal()` → only `openModal`
363
+
364
+ They were combined into **`useOverlay()`** which returns both:
365
+
366
+ ```tsx
367
+ return { openSidePanel, openModal, isOverlayOpen, stackDepth };
368
+ ```
369
+
370
+ ### Why deprecate instead of delete?
371
+
372
+ - **Backward compatibility** — if any old code imported `useSidePanel` or `useModal`, it still works
373
+ - **Gradual migration** — `@deprecated` shows a warning in IDE so devs switch to `useOverlay`
374
+ - **No usages left** — a grep shows these deprecated exports are **only defined, never imported** anywhere else in the codebase. Safe to remove eventually.
375
+
376
+ ### Why one hook is better
377
+
378
+ | Before (2 hooks) | After (1 hook) |
379
+ |------------------|----------------|
380
+ | Import `useSidePanel` for forms | Import `useOverlay` once |
381
+ | Import `useModal` for dialogs | Get both openers from same hook |
382
+ | Two context reads | One context read |
383
+ | Confusing which to use | Clear single entry point |
384
+
385
+ `useFormPanel` internally only needs `openSidePanel`, but it gets it from `useOverlay()` — no need for a separate `useSidePanel` import.
386
+
387
+ ---
388
+
389
+ ## 5. Why So Many Hooks Just to Open a Side Panel?
390
+
391
+ This is a fair question. The call chain is:
392
+
393
+ ```
394
+ CustomersPage
395
+ → useCustomerFormPanel()
396
+ → useFormPanel()
397
+ → useOverlay()
398
+ → useAppProvider()
399
+ → AppProvider.open()
400
+ → SidePanel renders
401
+ → CustomerForm
402
+ → SidePanelCard
403
+ → CustomerTab / AdvancedTab
404
+ ```
405
+
406
+ That's **4 hooks** before anything renders. Here's why each layer exists — and what you'd lose without it.
407
+
408
+ ### Could you simplify to one line?
409
+
410
+ Yes, technically:
411
+
412
+ ```tsx
413
+ // Hypothetical "simple" approach in CustomersPage
414
+ const { openSidePanel } = useOverlay();
415
+
416
+ <Btn onClick={() =>
417
+ openSidePanel(({ close }) => <CustomerForm mode="create" close={close} />)
418
+ }>
419
+ ```
420
+
421
+ That works. So why the layers?
422
+
423
+ ---
424
+
425
+ ### Layer-by-layer justification
426
+
427
+ | Layer | What it saves you from |
428
+ |-------|------------------------|
429
+ | **AppProvider + stack** | Managing portal, z-index, stacking multiple overlays, keyboard shortcuts, Escape handling — in every page manually |
430
+ | **useOverlay** | Remembering `APP_PROVIDER_TYPE.SIDE_PANEL`, default `onSide: "right"`, and the `({ close }) => JSX` render pattern every time |
431
+ | **useFormPanel** | Repeating `openSidePanel(({ close }) => <Component {...props} close={close} />)` in 10+ entity hooks; enforces `close` prop typing |
432
+ | **useCustomerFormPanel** | Page knowing about `CustomerForm` import, prop shapes, and mode/customer mapping |
433
+
434
+ ---
435
+
436
+ ### What the complexity actually buys you
437
+
438
+ #### 1. One overlay system for the whole app
439
+
440
+ Without `AppProvider`, every page would need its own modal/panel state:
441
+
442
+ ```tsx
443
+ // Without AppProvider — repeated on every page
444
+ const [showForm, setShowForm] = useState(false);
445
+ {showForm && <Portal><SidePanel>...</SidePanel></Portal>}
446
+ ```
447
+
448
+ With `AppProvider`, overlays stack correctly (form + confirm dialog on top), share Escape/keyboard behavior, and portal once at the root.
449
+
450
+ #### 2. Consistent form pattern across 10+ entities
451
+
452
+ These all use the same `useFormPanel` → `SidePanelCard` pattern:
453
+
454
+ - Customers, Agents, Shippers, Consignees, Carriers, Factoring, Roles, Fleet, Loads...
455
+
456
+ Adding a new entity is ~15 lines (form + domain hook), not re-building overlay plumbing.
457
+
458
+ #### 3. Domain hooks hide implementation
459
+
460
+ `CustomersPage` doesn't know:
461
+ - That a side panel is used (could switch to modal later)
462
+ - What component renders (`CustomerForm`)
463
+ - How `close` is injected
464
+
465
+ It only knows: `openCustomerForm("create")`.
466
+
467
+ #### 4. Type safety
468
+
469
+ `useFormPanel` enforces that every form component accepts `close: () => void`:
470
+
471
+ ```tsx
472
+ <P extends { close: () => void }>(
473
+ Component: ComponentType<P>,
474
+ props: Omit<P, "close">,
475
+ )
476
+ ```
477
+
478
+ TypeScript catches missing `close` at compile time.
479
+
480
+ #### 5. Cross-feature reuse
481
+
482
+ Create Load page opens Customer form from a different route:
483
+
484
+ ```tsx
485
+ // loads/create/hooks/use-create-load-panels.tsx
486
+ const openCustomerForm = useCustomerFormPanel();
487
+ // Can spawn customer form without duplicating overlay logic
488
+ ```
489
+
490
+ ---
491
+
492
+ ### Honest assessment: is it over-engineered?
493
+
494
+ | For this project | Verdict |
495
+ |------------------|---------|
496
+ | 10+ forms sharing one pattern | **Justified** — DRY pays off |
497
+ | Stack + keyboard + unsaved guard | **Justified** — hard to redo per page |
498
+ | `useCustomerFormPanel` wrapper | **Light but useful** — keeps pages clean |
499
+ | `useFormPanel` generic wrapper | **Justified** — removes boilerplate across entities |
500
+ | Deprecated `useSidePanel`/`useModal` | **Cleanup debt** — can delete, nothing uses them |
501
+
502
+ | If you only had 1–2 forms ever | Verdict |
503
+ |----------------------------------|---------|
504
+ | Full hook chain | **Overkill** — direct `openSidePanel` in the page would be fine |
505
+
506
+ **Bottom line:** The nesting looks heavy for one button click, but most layers are **infrastructure you pay once** (AppProvider, useOverlay, useFormPanel). Per-entity cost is only the thin domain hook (`useCustomerFormPanel` — 8 lines).
507
+
508
+ ---
509
+
510
+ ### Minimal vs current approach (comparison)
511
+
512
+ **Minimal (1 form in the app):**
513
+
514
+ ```tsx
515
+ const { openSidePanel } = useOverlay();
516
+ openSidePanel(({ close }) => <CustomerForm close={close} mode="create" />);
517
+ ```
518
+
519
+ **Current (scalable across app):**
520
+
521
+ ```tsx
522
+ const openCustomerForm = useCustomerFormPanel();
523
+ openCustomerForm("create");
524
+ ```
525
+
526
+ The current approach trades 2 extra hook files for:
527
+ - Readable page code
528
+ - Shared overlay behavior
529
+ - Easy addition of new entity forms
530
+ - Type-safe `close` injection
531
+
532
+ ---
533
+
534
+ ## Quick Reference
535
+
536
+ ### SidePanelCard — you provide
537
+
538
+ ```tsx
539
+ <SidePanelCard
540
+ title="Add Customer"
541
+ close={close}
542
+ onSubmit={handleSave}
543
+ isDirty={isDirty}
544
+ size="lg"
545
+ headerSlot={<FormTabs ... />}
546
+ footer={<Cancel + Save buttons>}
547
+ >
548
+ {/* tab content — CustomerTab or AdvancedTab */}
549
+ </SidePanelCard>
550
+ ```
551
+
552
+ ### Tab components — you provide
553
+
554
+ ```tsx
555
+ <CustomerTab values={values} setField={setField} />
556
+ <AdvancedTab values={values} setField={setField} />
557
+ ```
558
+
559
+ Both read/write the **same** `values` object owned by `CustomerForm`.
560
+
561
+ ### Hooks — naming at each level
562
+
563
+ ```
564
+ openSidePanel ← generic overlay (useOverlay)
565
+ openForm ← generic form-in-panel (useFormPanel)
566
+ openCustomerForm ← customer-specific (useCustomerFormPanel)
567
+ ```
568
+
569
+ ### Deprecated — use instead
570
+
571
+ ```tsx
572
+ // Don't use
573
+ useSidePanel() → useOverlay()
574
+ useModal() → useOverlay()
575
+ ```
576
+
577
+ ---
578
+
@@ -0,0 +1,92 @@
1
+ import type { Provider as Props } from "@/types";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Modal, SidePanel, SidePanelContent } from "../components/base/";
4
+ import { APP_PROVIDER_TYPE } from "@/utils";
5
+ import { AppContext } from "./app-context";
6
+ import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
7
+
8
+ export { useAppProvider } from "./app-context";
9
+
10
+ export const AppProvider = ({ children }: Props.ProviderProps) => {
11
+ const [stack, setStack] = useState<Props.OverlayStackItem[]>([]);
12
+
13
+ const open = useCallback(
14
+ (
15
+ type: string,
16
+ content?: Props.OverlayContent,
17
+ options?: Props.OverlayOptions,
18
+ ) => {
19
+ const id = crypto.randomUUID();
20
+ setStack((prev) => [...prev, { id, type, content, options }]);
21
+ },
22
+ [],
23
+ );
24
+
25
+ const close = useCallback((id: string) => {
26
+ setStack((prev) => prev.filter((item) => item.id !== id));
27
+ }, []);
28
+
29
+ const isOverlayOpen = stack.length > 0;
30
+
31
+ const openModal = useCallback(
32
+ (content: Props.OverlayContent, options?: Props.OverlayOptions) => {
33
+ open(APP_PROVIDER_TYPE.MODAL, content, options);
34
+ },
35
+ [open],
36
+ );
37
+
38
+ useKeyboardShortcuts(isOverlayOpen, openModal);
39
+
40
+ const contextValue = useMemo(
41
+ () => ({
42
+ open,
43
+ close,
44
+ stack,
45
+ isOverlayOpen,
46
+ stackDepth: stack.length,
47
+ }),
48
+ [open, close, stack, isOverlayOpen],
49
+ );
50
+
51
+ const topOverlayId = stack.at(-1)?.id;
52
+
53
+ return (
54
+ <AppContext.Provider value={contextValue}>
55
+ {children}
56
+ {stack.map((item, index) => {
57
+ const isActive = item.id === topOverlayId;
58
+ const layer = index + 1;
59
+
60
+ if (item.type === APP_PROVIDER_TYPE.MODAL) {
61
+ return (
62
+ <Modal
63
+ key={item.id}
64
+ options={item.options || {}}
65
+ isActive={isActive}
66
+ layer={layer}
67
+ onClose={() => close(item.id)}
68
+ >
69
+ {item.content?.({ close: () => close(item.id) })}
70
+ </Modal>
71
+ );
72
+ }
73
+
74
+ if (item.type === APP_PROVIDER_TYPE.SIDE_PANEL) {
75
+ return (
76
+ <SidePanel
77
+ key={item.id}
78
+ options={item.options || {}}
79
+ isActive={isActive}
80
+ layer={layer}
81
+ onClose={() => close(item.id)}
82
+ >
83
+ <SidePanelContent content={item.content} />
84
+ </SidePanel>
85
+ );
86
+ }
87
+
88
+ return null;
89
+ })}
90
+ </AppContext.Provider>
91
+ );
92
+ };
@@ -0,0 +1,14 @@
1
+ import type { Provider as Props } from "@/types";
2
+ import { createContext, useContext } from "react";
3
+
4
+ export const AppContext = createContext<Props.AppContextProps | undefined>(
5
+ undefined,
6
+ );
7
+
8
+ export function useAppProvider() {
9
+ const context = useContext(AppContext);
10
+ if (!context) {
11
+ throw new Error("useAppProvider must be used within AppProvider");
12
+ }
13
+ return context;
14
+ }