pejay-ui 1.4.3 → 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.
- package/README.md +26 -0
- package/bin/cli.js +45 -15
- package/package.json +2 -1
- package/registry/buttons.json +3 -2
- package/registry/dropdowns.json +3 -1
- package/registry/forms.json +51 -23
- package/registry/hotkeys.json +12 -0
- package/registry/overlays.json +18 -2
- package/registry/panels.json +21 -0
- package/registry/skeleton.json +20 -0
- package/registry/spinner.json +13 -0
- package/templates/button/Button.tsx +8 -7
- package/templates/button/README.md +81 -0
- package/templates/button/index.ts +1 -2
- package/templates/form/{checkbox-group.tsx → choices/checkbox-group.tsx} +1 -1
- package/templates/form/{checkbox.tsx → choices/checkbox.tsx} +1 -1
- package/templates/form/{radio-group.tsx → choices/radio-group.tsx} +1 -1
- package/templates/form/{radio.tsx → choices/radio.tsx} +1 -1
- package/templates/form/choices/readme.checkbox-group.md +27 -0
- package/templates/form/choices/readme.checkbox.md +26 -0
- package/templates/form/choices/readme.radio-group.md +26 -0
- package/templates/form/choices/readme.radio.md +24 -0
- package/templates/form/choices/readme.switch.md +26 -0
- package/templates/form/{switch.tsx → choices/switch.tsx} +1 -1
- package/templates/form/{file-input.tsx → file/file-input.tsx} +2 -2
- package/templates/form/file/readme.file-input.md +26 -0
- package/templates/form/index.ts +19 -22
- package/templates/form/{amount-input.tsx → numeric/amount-input.tsx} +2 -2
- package/templates/form/{number-input.tsx → numeric/number-input.tsx} +2 -2
- package/templates/form/{range-slider.tsx → numeric/range-slider.tsx} +1 -1
- package/templates/form/numeric/readme.amount-input.md +27 -0
- package/templates/form/numeric/readme.number-input.md +26 -0
- package/templates/form/numeric/readme.range-slider.md +27 -0
- package/templates/form/{date-picker.tsx → pickers/date-picker.tsx} +2 -2
- package/templates/form/{date-range-picker.tsx → pickers/date-range-picker.tsx} +2 -2
- package/templates/form/pickers/readme.date-picker.md +26 -0
- package/templates/form/pickers/readme.date-range-picker.md +25 -0
- package/templates/form/pickers/readme.time-picker.md +25 -0
- package/templates/form/pickers/readme.time-range-picker.md +25 -0
- package/templates/form/{time-picker.tsx → pickers/time-picker.tsx} +1 -1
- package/templates/form/{time-range-picker.tsx → pickers/time-range-picker.tsx} +1 -1
- package/templates/form/{input.tsx → text-inputs/input.tsx} +1 -1
- package/templates/form/{password-input.tsx → text-inputs/password-input.tsx} +1 -1
- package/templates/form/text-inputs/readme.email-input.md +24 -0
- package/templates/form/text-inputs/readme.input.md +28 -0
- package/templates/form/text-inputs/readme.password-input.md +24 -0
- package/templates/form/text-inputs/readme.phone-input.md +24 -0
- package/templates/form/text-inputs/readme.textarea.md +24 -0
- package/templates/form/text-inputs/readme.url-input.md +23 -0
- package/templates/form/{textarea.tsx → text-inputs/textarea.tsx} +1 -1
- package/templates/hotkeys/README.md +134 -0
- package/templates/hotkeys/components/HotkeyProvider.tsx +78 -0
- package/templates/hotkeys/components/HotkeysHelpModal.tsx +102 -0
- package/templates/hotkeys/core/key-matcher.ts +106 -0
- package/templates/hotkeys/core/registry.ts +39 -0
- package/templates/hotkeys/core/types.ts +15 -0
- package/templates/hotkeys/hooks/useHotkey.ts +43 -0
- package/templates/hotkeys/index.ts +6 -0
- package/templates/layouts/lv1/app-layout.tsx +1 -1
- package/templates/layouts/lv1/sidebar-menu.tsx +1 -1
- package/templates/notes/app-provider/app-provider-side-panel-modals-roadmap.md +606 -0
- package/templates/notes/app-provider/manual-open-close-side-panel-and-modal.md +913 -0
- package/templates/notes/app-provider/side-panel-card-hooks-and-complexity.md +578 -0
- package/templates/notes/under-dev/AppProvider.tsx +92 -0
- package/templates/notes/under-dev/app-context.ts +14 -0
- package/templates/notes/under-dev/card/base-card.tsx +35 -0
- package/templates/notes/under-dev/card/index.ts +4 -0
- package/templates/notes/under-dev/card/modal-card.tsx +88 -0
- package/templates/notes/under-dev/card/side-panel-card.tsx +127 -0
- package/templates/notes/under-dev/form-overlay-registry.ts +42 -0
- package/templates/notes/under-dev/keyboard-shortcuts-help.tsx +79 -0
- package/templates/notes/under-dev/keyboard-utils.ts +22 -0
- package/templates/notes/under-dev/overlay/backdrop.tsx +95 -0
- package/templates/notes/under-dev/overlay/index.ts +4 -0
- package/templates/notes/under-dev/overlay/modal.tsx +43 -0
- package/templates/notes/under-dev/overlay/side-panel.tsx +126 -0
- package/templates/notes/under-dev/overlay-close.ts +50 -0
- package/templates/notes/under-dev/page-shortcut-registry.ts +9 -0
- package/templates/notes/under-dev/unsaved-changes-notify.ts +11 -0
- package/templates/notes/under-dev/use-keyboard-shortcuts.tsx +110 -0
- package/templates/notes/under-dev/useFormDirty.ts +6 -0
- package/templates/notes/under-dev/useFormOverlayRegistration.ts +47 -0
- package/templates/notes/under-dev/useFormPanel.tsx +18 -0
- package/templates/notes/under-dev/useFormTabHandler.ts +22 -0
- package/templates/notes/under-dev/useHorizontalWheelScroll.ts +27 -0
- package/templates/notes/under-dev/useOverlay.ts +41 -0
- package/templates/overlays/index.ts +2 -1
- package/templates/overlays/portal/portal.tsx +26 -0
- package/templates/overlays/tooltip/readme.tooltip.md +26 -0
- package/templates/{button → overlays/tooltip}/tooltip.tsx +1 -1
- package/templates/panels/COMPONENTS.md +103 -0
- package/templates/panels/README.md +702 -0
- package/templates/panels/components/base-card.tsx +33 -0
- package/templates/panels/components/index.ts +8 -0
- package/templates/panels/components/modal/backdrop.tsx +88 -0
- package/templates/panels/components/modal/modal-card.tsx +139 -0
- package/templates/panels/components/modal/modal-raw.tsx +36 -0
- package/templates/panels/components/modal/modal.tsx +49 -0
- package/templates/panels/components/side-panel/side-panel-card.tsx +123 -0
- package/templates/panels/components/side-panel/side-panel-raw.tsx +25 -0
- package/templates/panels/components/side-panel/side-panel.tsx +135 -0
- package/templates/panels/core/PanelProvider.tsx +145 -0
- package/templates/panels/core/constants.ts +9 -0
- package/templates/panels/core/form-overlay-registry.ts +35 -0
- package/templates/panels/core/index.ts +6 -0
- package/templates/panels/core/overlay-close.ts +11 -0
- package/templates/panels/core/panel-context.ts +41 -0
- package/templates/panels/core/types.ts +41 -0
- package/templates/panels/hooks/index.ts +7 -0
- package/templates/panels/hooks/useFormDirty.ts +6 -0
- package/templates/panels/hooks/useFormOverlayRegistration.ts +92 -0
- package/templates/panels/hooks/useFormPanel.tsx +18 -0
- package/templates/panels/hooks/useFormTabHandler.ts +25 -0
- package/templates/panels/hooks/useHorizontalWheelScroll.ts +31 -0
- package/templates/panels/hooks/useModalForm.tsx +22 -0
- package/templates/panels/hooks/useOverlay.ts +65 -0
- package/templates/panels/index.ts +3 -0
- package/templates/panels/vendor-example/by-using-modal/VendorModalPage.tsx +47 -0
- package/templates/panels/vendor-example/by-using-modal/useVendorModalForm.tsx +19 -0
- package/templates/panels/vendor-example/by-using-modal/vendor-modal-form.tsx +112 -0
- package/templates/panels/vendor-example/by-using-modal/vendor-types.ts +29 -0
- package/templates/panels/vendor-example/by-using-sidepanel/VendorsPage.tsx +47 -0
- package/templates/panels/vendor-example/by-using-sidepanel/useVendorFormPanel.tsx +15 -0
- package/templates/panels/vendor-example/by-using-sidepanel/vendor-form.tsx +108 -0
- package/templates/panels/vendor-example/by-using-sidepanel/vendor-types.ts +29 -0
- package/templates/select-dropdown/README.md +62 -0
- package/templates/select-dropdown/multiselect-input.tsx +2 -2
- package/templates/select-dropdown/select-input.tsx +2 -2
- package/templates/skeleton/README.md +53 -0
- package/templates/skeleton/index.ts +2 -0
- package/templates/skeleton/skeleton.css +36 -0
- package/templates/skeleton/skeleton.tsx +40 -0
- package/templates/skeleton/types.ts +12 -0
- package/templates/spinner/README.md +51 -0
- package/templates/spinner/index.ts +1 -0
- package/templates/spinner/spinner.css +58 -0
- package/templates/spinner/spinner.tsx +263 -0
- package/templates/toast/container.tsx +2 -2
- package/templates/utilities/formater.dateTime.md +74 -0
- package/templates/utilities/formater.dateTime.ts +310 -0
- package/templates/utilities/formater.phoneNumber.md +32 -0
- package/templates/utilities/formater.phoneNumber.ts +143 -0
- package/templates/utilities/sanitize.md +23 -0
- package/templates/utilities/sanitize.ts +148 -0
- /package/templates/form/{email-input.tsx → text-inputs/email-input.tsx} +0 -0
- /package/templates/form/{phone-input.tsx → text-inputs/phone-input.tsx} +0 -0
- /package/templates/form/{url-input.tsx → text-inputs/url-input.tsx} +0 -0
- /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
|
+
}
|