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,913 @@
|
|
|
1
|
+
# Manual: How to Open & Close a Side Panel and Modal (Step by Step)
|
|
2
|
+
|
|
3
|
+
> A complete hands-on guide for building overlays from scratch in this project.
|
|
4
|
+
> Uses **fictional demo names** (Vendor, not Customer) so you can follow without copying real route code.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Before You Start — One Rule
|
|
9
|
+
|
|
10
|
+
**Never render `<SidePanel>` or `<Modal>` directly inside a page.**
|
|
11
|
+
|
|
12
|
+
Always call `openSidePanel()` or `openModal()` from a hook.
|
|
13
|
+
`AppProvider` (already mounted in `App.tsx`) renders the overlay for you.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Demo Names Used in This Guide
|
|
18
|
+
|
|
19
|
+
| Real project pattern | Demo name in this guide |
|
|
20
|
+
|----------------------|-------------------------|
|
|
21
|
+
| Customer | **Vendor** |
|
|
22
|
+
| CustomerForm | **VendorForm** |
|
|
23
|
+
| useCustomerFormPanel | **useVendorFormPanel** |
|
|
24
|
+
| openCustomerForm | **openVendorForm** |
|
|
25
|
+
| ViewAssetDialog | **ViewVendorDialog** |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# PART A — Side Panel (Form with Data)
|
|
30
|
+
|
|
31
|
+
Use a side panel when the user needs to **create or edit** something (a form).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Step 0 — Confirm AppProvider Exists (Prerequisite)
|
|
36
|
+
|
|
37
|
+
**File:** `src/App.tsx`
|
|
38
|
+
|
|
39
|
+
Your app must already have this (it does in this project):
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
<AppProvider>
|
|
43
|
+
<RouterProvider router={router} />
|
|
44
|
+
</AppProvider>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If `AppProvider` is missing, **no overlay will ever show**. Stop here and add it first.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Step 1 — Define Your Data Type
|
|
52
|
+
|
|
53
|
+
**File:** `src/routes/vendors/data/vendors.ts` *(new file)*
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
export type Vendor = {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
email: string;
|
|
60
|
+
phone: string;
|
|
61
|
+
status: "active" | "inactive";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const VENDORS: Vendor[] = [
|
|
65
|
+
{ id: "V-001", name: "Acme Supplies", email: "acme@mail.com", phone: "555-0100", status: "active" },
|
|
66
|
+
];
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This is the shape of data coming **from your table/API** when editing.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Step 2 — Define Form Values + Defaults
|
|
74
|
+
|
|
75
|
+
**File:** `src/routes/vendors/data/vendor-form.ts` *(new file)*
|
|
76
|
+
|
|
77
|
+
Form values can differ from table data (extra fields, different key names).
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import type { Vendor } from "./vendors";
|
|
81
|
+
|
|
82
|
+
export type VendorFormMode = "create" | "update";
|
|
83
|
+
|
|
84
|
+
export type VendorFormValues = {
|
|
85
|
+
vendorName: string;
|
|
86
|
+
vendorEmail: string;
|
|
87
|
+
vendorPhone: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function getDefaultVendorFormValues(): VendorFormValues {
|
|
91
|
+
return {
|
|
92
|
+
vendorName: "",
|
|
93
|
+
vendorEmail: "",
|
|
94
|
+
vendorPhone: "",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Convert table row → form fields (for edit mode) */
|
|
99
|
+
export function vendorToFormValues(vendor: Vendor): VendorFormValues {
|
|
100
|
+
return {
|
|
101
|
+
vendorName: vendor.name,
|
|
102
|
+
vendorEmail: vendor.email,
|
|
103
|
+
vendorPhone: vendor.phone,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Why two types (`Vendor` vs `VendorFormValues`)?**
|
|
109
|
+
Table data and form fields are often shaped differently. Mapping keeps the form independent.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Step 3 — Build the Form Component
|
|
114
|
+
|
|
115
|
+
**File:** `src/routes/vendors/components/vendor-form/index.tsx` *(new file)*
|
|
116
|
+
|
|
117
|
+
### Required rules for every form overlay component
|
|
118
|
+
|
|
119
|
+
1. Must accept `close: () => void` as a prop (injected automatically by hooks)
|
|
120
|
+
2. Must wrap content in `<SidePanelCard>`
|
|
121
|
+
3. Must call `close()` after successful save
|
|
122
|
+
4. Use `requestOverlayCloseWithConfirm()` for Cancel — not raw `close()`
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import { useCallback, useState } from "react";
|
|
126
|
+
import { Btn, SidePanelCard } from "@/components/base";
|
|
127
|
+
import { FormField, LightInput } from "@/components/shared";
|
|
128
|
+
import { useFormDirty } from "@/hooks";
|
|
129
|
+
import { requestOverlayCloseWithConfirm } from "@/hooks/overlay-close";
|
|
130
|
+
import { toast } from "@/components/base/toast";
|
|
131
|
+
import type { Vendor } from "../../data/vendors";
|
|
132
|
+
import {
|
|
133
|
+
getDefaultVendorFormValues,
|
|
134
|
+
vendorToFormValues,
|
|
135
|
+
type VendorFormMode,
|
|
136
|
+
type VendorFormValues,
|
|
137
|
+
} from "../../data/vendor-form";
|
|
138
|
+
|
|
139
|
+
export type VendorFormProps = {
|
|
140
|
+
close: () => void; // REQUIRED — do not omit
|
|
141
|
+
mode?: VendorFormMode;
|
|
142
|
+
vendor?: Vendor; // passed when editing
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function VendorForm({ close, mode = "create", vendor }: VendorFormProps) {
|
|
146
|
+
const isUpdate = mode === "update";
|
|
147
|
+
|
|
148
|
+
// ── STATE: initialize with defaults, merge vendor data if editing ──
|
|
149
|
+
const [values, setValues] = useState<VendorFormValues>(() => ({
|
|
150
|
+
...getDefaultVendorFormValues(),
|
|
151
|
+
...(vendor ? vendorToFormValues(vendor) : {}),
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const setField = useCallback(
|
|
155
|
+
<K extends keyof VendorFormValues>(key: K, value: VendorFormValues[K]) => {
|
|
156
|
+
setValues((prev) => ({ ...prev, [key]: value }));
|
|
157
|
+
},
|
|
158
|
+
[],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const isDirty = useFormDirty(values);
|
|
162
|
+
|
|
163
|
+
const handleSave = () => {
|
|
164
|
+
if (!values.vendorName.trim()) {
|
|
165
|
+
toast.error("Vendor name is required");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// TODO: call API here
|
|
170
|
+
toast.success(isUpdate ? "Vendor updated!" : "Vendor created!");
|
|
171
|
+
close(); // ← closes the side panel (see Part A — Closing section)
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<SidePanelCard
|
|
176
|
+
title={isUpdate ? "Edit Vendor" : "Add Vendor"}
|
|
177
|
+
close={close}
|
|
178
|
+
onSubmit={handleSave}
|
|
179
|
+
isDirty={isDirty}
|
|
180
|
+
size="md"
|
|
181
|
+
footer={
|
|
182
|
+
<div className="flex justify-end gap-3">
|
|
183
|
+
<Btn variant="outline" onClick={requestOverlayCloseWithConfirm}>
|
|
184
|
+
Cancel
|
|
185
|
+
</Btn>
|
|
186
|
+
<Btn variant="primary" onClick={handleSave}>
|
|
187
|
+
Save
|
|
188
|
+
</Btn>
|
|
189
|
+
</div>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<FormField label="Vendor Name" required>
|
|
193
|
+
<LightInput
|
|
194
|
+
value={values.vendorName}
|
|
195
|
+
onChange={(v) => setField("vendorName", v)}
|
|
196
|
+
/>
|
|
197
|
+
</FormField>
|
|
198
|
+
|
|
199
|
+
<FormField label="Email">
|
|
200
|
+
<LightInput
|
|
201
|
+
value={values.vendorEmail}
|
|
202
|
+
onChange={(v) => setField("vendorEmail", v)}
|
|
203
|
+
type="email"
|
|
204
|
+
/>
|
|
205
|
+
</FormField>
|
|
206
|
+
|
|
207
|
+
<FormField label="Phone">
|
|
208
|
+
<LightInput
|
|
209
|
+
value={values.vendorPhone}
|
|
210
|
+
onChange={(v) => setField("vendorPhone", v)}
|
|
211
|
+
type="tel"
|
|
212
|
+
/>
|
|
213
|
+
</FormField>
|
|
214
|
+
</SidePanelCard>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Step 4 — Create the Domain Hook
|
|
222
|
+
|
|
223
|
+
**File:** `src/routes/vendors/hooks/use-vendor-form-panel.tsx` *(new file)*
|
|
224
|
+
|
|
225
|
+
This hook connects your form component to the overlay system.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { useCallback } from "react";
|
|
229
|
+
import { useFormPanel } from "@/hooks";
|
|
230
|
+
import { VendorForm } from "../components/vendor-form";
|
|
231
|
+
import type { Vendor } from "../data/vendors";
|
|
232
|
+
import type { VendorFormMode } from "../data/vendor-form";
|
|
233
|
+
|
|
234
|
+
export function useVendorFormPanel() {
|
|
235
|
+
const openForm = useFormPanel();
|
|
236
|
+
|
|
237
|
+
return useCallback(
|
|
238
|
+
(mode: VendorFormMode = "create", vendor?: Vendor) => {
|
|
239
|
+
openForm(VendorForm, { mode, vendor });
|
|
240
|
+
},
|
|
241
|
+
[openForm],
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**What happens inside `openForm` (you don't write this — it already exists):**
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
// src/hooks/useFormPanel.tsx (existing — do not duplicate)
|
|
250
|
+
openSidePanel(({ close }) => (
|
|
251
|
+
<VendorForm mode={mode} vendor={vendor} close={close} />
|
|
252
|
+
));
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Step 5 — Call It From Your Page
|
|
258
|
+
|
|
259
|
+
**File:** `src/routes/vendors/index.tsx` *(new file)*
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { Btn } from "@/components/base";
|
|
263
|
+
import { useVendorFormPanel } from "./hooks/use-vendor-form-panel";
|
|
264
|
+
import type { Vendor } from "./data/vendors";
|
|
265
|
+
|
|
266
|
+
export default function VendorsPage() {
|
|
267
|
+
const openVendorForm = useVendorFormPanel();
|
|
268
|
+
|
|
269
|
+
const handleCreate = () => {
|
|
270
|
+
openVendorForm("create"); // no data — empty form
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const handleEdit = (vendor: Vendor) => {
|
|
274
|
+
openVendorForm("update", vendor); // passes row data into form
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div>
|
|
279
|
+
<Btn onClick={handleCreate}>Add Vendor</Btn>
|
|
280
|
+
<Btn onClick={() => handleEdit({ id: "V-001", name: "Acme", email: "a@b.com", phone: "555", status: "active" })}>
|
|
281
|
+
Edit First Vendor
|
|
282
|
+
</Btn>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Step 6 — What Happens When You Click "Add Vendor" (Open Flow)
|
|
291
|
+
|
|
292
|
+
Follow this exact order. Every step matters.
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
STEP 6.1 User clicks "Add Vendor"
|
|
296
|
+
→ openVendorForm("create") runs
|
|
297
|
+
|
|
298
|
+
STEP 6.2 useVendorFormPanel calls:
|
|
299
|
+
→ openForm(VendorForm, { mode: "create" })
|
|
300
|
+
(no vendor prop — form starts empty)
|
|
301
|
+
|
|
302
|
+
STEP 6.3 useFormPanel calls:
|
|
303
|
+
→ openSidePanel(({ close }) => <VendorForm mode="create" close={close} />)
|
|
304
|
+
|
|
305
|
+
STEP 6.4 useOverlay calls:
|
|
306
|
+
→ open("side-panel", contentFn, { onSide: "right" })
|
|
307
|
+
|
|
308
|
+
STEP 6.5 AppProvider.open() runs:
|
|
309
|
+
→ creates id = crypto.randomUUID()
|
|
310
|
+
→ pushes { id, type: "side-panel", content, options } onto stack
|
|
311
|
+
→ React re-renders AppProvider
|
|
312
|
+
|
|
313
|
+
STEP 6.6 AppProvider renders from stack:
|
|
314
|
+
→ <SidePanel isActive={true} onClose={() => close(id)}>
|
|
315
|
+
<SidePanelContent content={contentFn} />
|
|
316
|
+
</SidePanel>
|
|
317
|
+
|
|
318
|
+
STEP 6.7 SidePanel mounts:
|
|
319
|
+
→ renders via Portal (on top of entire app)
|
|
320
|
+
→ sets body overflow = hidden (page can't scroll behind panel)
|
|
321
|
+
→ plays slide-in animation from right
|
|
322
|
+
→ registers animated close handler in form-overlay-registry
|
|
323
|
+
|
|
324
|
+
STEP 6.8 SidePanelContent runs:
|
|
325
|
+
→ gets animated close via useAnimatedSidePanelClose()
|
|
326
|
+
→ calls contentFn({ close: animatedClose })
|
|
327
|
+
→ renders <VendorForm mode="create" close={animatedClose} />
|
|
328
|
+
|
|
329
|
+
STEP 6.9 VendorForm mounts:
|
|
330
|
+
→ useState initializes values from getDefaultVendorFormValues()
|
|
331
|
+
→ SidePanelCard registers form (onSubmit, isDirty) for keyboard shortcuts
|
|
332
|
+
→ fields render empty, ready for input
|
|
333
|
+
|
|
334
|
+
DONE — panel is open, user sees the form
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Passing data in edit mode — what changes
|
|
338
|
+
|
|
339
|
+
Only **Step 6.2** and **Step 6.9** differ:
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
openVendorForm("update", vendor)
|
|
343
|
+
→ openForm(VendorForm, { mode: "update", vendor: vendorObject })
|
|
344
|
+
|
|
345
|
+
VendorForm useState init:
|
|
346
|
+
→ { ...getDefaultVendorFormValues(), ...vendorToFormValues(vendor) }
|
|
347
|
+
→ fields pre-filled with vendor.name → vendorName, etc.
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The overlay machinery (Steps 6.3–6.8) is identical for create and edit.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Step 7 — How to Close the Side Panel (All Paths)
|
|
355
|
+
|
|
356
|
+
There are **5 ways** a user can close. Each path is different.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
### Path 1 — Save button (happy path)
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
User clicks Save
|
|
364
|
+
→ handleSave() runs
|
|
365
|
+
→ validation passes
|
|
366
|
+
→ API call (optional)
|
|
367
|
+
→ close() called directly
|
|
368
|
+
|
|
369
|
+
close() here = animated close (from SidePanelContent)
|
|
370
|
+
→ SidePanel sets isOpen = false
|
|
371
|
+
→ slide-out animation plays (~200ms)
|
|
372
|
+
→ AnimatePresence onExitComplete fires
|
|
373
|
+
→ AppProvider.close(id) removes item from stack
|
|
374
|
+
→ SidePanel unmounts
|
|
375
|
+
→ body overflow restored
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Use `close()` directly on save** — no unsaved-changes check needed (user chose to save).
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
### Path 2 — Cancel button
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
User clicks Cancel
|
|
386
|
+
→ requestOverlayCloseWithConfirm() runs
|
|
387
|
+
|
|
388
|
+
Inside requestOverlayCloseWithConfirm:
|
|
389
|
+
1. Is unsaved confirm dialog already open? → dismiss it, stop
|
|
390
|
+
2. Is form close blocked (closeDisabled)? → stop
|
|
391
|
+
3. Is form dirty (isDirty = true)? → show "Unsaved changes" notify, stop
|
|
392
|
+
4. Otherwise → call animated close()
|
|
393
|
+
|
|
394
|
+
If dirty → user sees notify dialog:
|
|
395
|
+
→ "Discard" → animated close runs → panel closes
|
|
396
|
+
→ "Keep editing" / dismiss → panel stays open
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Always use `requestOverlayCloseWithConfirm()` for Cancel** — never raw `close()` on Cancel.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
### Path 3 — X button (top right of SidePanelCard)
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
User clicks X
|
|
407
|
+
→ SidePanelCard.handleClose()
|
|
408
|
+
→ requestOverlayCloseWithConfirm()
|
|
409
|
+
→ same dirty-check flow as Cancel (Path 2)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### Path 4 — Click backdrop (outside the panel)
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
User clicks the blurred area behind the panel
|
|
418
|
+
→ SidePanel backdrop onClick
|
|
419
|
+
→ requestOverlayCloseWithConfirm()
|
|
420
|
+
→ same dirty-check flow as Cancel (Path 2)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
### Path 5 — Press Escape key
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
User presses Escape
|
|
429
|
+
→ useKeyboardShortcuts (in AppProvider) catches it
|
|
430
|
+
→ requestOverlayCloseWithConfirm()
|
|
431
|
+
→ same dirty-check flow as Cancel (Path 2)
|
|
432
|
+
|
|
433
|
+
Edge case: if unsaved confirm dialog is already open
|
|
434
|
+
→ Escape dismisses the notify dialog first
|
|
435
|
+
→ panel stays open
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### Close flow diagram (all paths)
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
┌─────────────────┐
|
|
444
|
+
│ User wants to │
|
|
445
|
+
│ close panel │
|
|
446
|
+
└────────┬────────┘
|
|
447
|
+
│
|
|
448
|
+
┌──────────────┼──────────────┐
|
|
449
|
+
│ │ │
|
|
450
|
+
[Save] [Cancel/X/ [Escape]
|
|
451
|
+
│ Backdrop] │
|
|
452
|
+
│ │ │
|
|
453
|
+
▼ ▼ ▼
|
|
454
|
+
handleSave() requestOverlay requestOverlay
|
|
455
|
+
│ CloseWithConfirm CloseWithConfirm
|
|
456
|
+
│ │ │
|
|
457
|
+
▼ ▼ │
|
|
458
|
+
close() isDirty? ────────────┘
|
|
459
|
+
│ / \
|
|
460
|
+
│ yes no
|
|
461
|
+
│ │ │
|
|
462
|
+
│ show notify animated
|
|
463
|
+
│ dialog close()
|
|
464
|
+
│ │
|
|
465
|
+
│ [Discard] → animated close()
|
|
466
|
+
│
|
|
467
|
+
▼
|
|
468
|
+
animated close()
|
|
469
|
+
│
|
|
470
|
+
▼
|
|
471
|
+
slide-out animation
|
|
472
|
+
│
|
|
473
|
+
▼
|
|
474
|
+
AppProvider.close(id)
|
|
475
|
+
│
|
|
476
|
+
▼
|
|
477
|
+
panel removed from DOM
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Step 8 — Edge Cases for Side Panels (Do Not Skip)
|
|
483
|
+
|
|
484
|
+
### Edge case 1 — Form is dirty
|
|
485
|
+
|
|
486
|
+
If user typed anything and tries to close via Cancel/X/backdrop/Escape:
|
|
487
|
+
|
|
488
|
+
- Panel does **not** close immediately
|
|
489
|
+
- "Unsaved changes" notify appears
|
|
490
|
+
- User must confirm discard or keep editing
|
|
491
|
+
|
|
492
|
+
**Fix:** pass `isDirty={true}` to `SidePanelCard` (via `useFormDirty(values)`).
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
### Edge case 2 — Save in progress (async)
|
|
497
|
+
|
|
498
|
+
If your save takes time (API call), block accidental close:
|
|
499
|
+
|
|
500
|
+
```tsx
|
|
501
|
+
const [saving, setSaving] = useState(false);
|
|
502
|
+
|
|
503
|
+
<SidePanelCard closeDisabled={saving} ...>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
While `closeDisabled={true}`:
|
|
507
|
+
- X button is disabled
|
|
508
|
+
- Escape does nothing
|
|
509
|
+
- Backdrop click does nothing
|
|
510
|
+
|
|
511
|
+
**Still call `close()` yourself after save succeeds.**
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
### Edge case 3 — Validation failure
|
|
516
|
+
|
|
517
|
+
If validation fails, **do not call `close()`**:
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
const handleSave = () => {
|
|
521
|
+
if (!values.vendorName.trim()) {
|
|
522
|
+
toast.error("Vendor name is required");
|
|
523
|
+
return; // ← panel stays open
|
|
524
|
+
}
|
|
525
|
+
close();
|
|
526
|
+
};
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
### Edge case 4 — Opening panel from inside another page/feature
|
|
532
|
+
|
|
533
|
+
You can import and use `useVendorFormPanel()` from any route:
|
|
534
|
+
|
|
535
|
+
```tsx
|
|
536
|
+
// Some other page
|
|
537
|
+
import { useVendorFormPanel } from "@/routes/vendors/hooks/use-vendor-form-panel";
|
|
538
|
+
|
|
539
|
+
const openVendorForm = useVendorFormPanel();
|
|
540
|
+
openVendorForm("create");
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
No extra setup needed — AppProvider is global.
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
### Edge case 5 — Multiple overlays stacked
|
|
548
|
+
|
|
549
|
+
If a side panel is open and something opens a modal on top:
|
|
550
|
+
|
|
551
|
+
```
|
|
552
|
+
stack = [ side-panel-item, modal-item ]
|
|
553
|
+
↑ not active ↑ active (only this receives Escape/clicks)
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- Only the **top** overlay is interactive
|
|
557
|
+
- Lower overlay stays mounted but invisible
|
|
558
|
+
- When top closes, lower panel becomes active again
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
### Edge case 6 — Side panel from left instead of right
|
|
563
|
+
|
|
564
|
+
```tsx
|
|
565
|
+
// If using useOverlay directly (not useFormPanel):
|
|
566
|
+
openSidePanel(
|
|
567
|
+
({ close }) => <VendorForm close={close} />,
|
|
568
|
+
{ onSide: "left" }
|
|
569
|
+
);
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Default is `"right"`. `useFormPanel` always uses right.
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
### Edge case 7 — Ctrl+Enter to submit
|
|
577
|
+
|
|
578
|
+
If you pass `onSubmit={handleSave}` to `SidePanelCard`:
|
|
579
|
+
|
|
580
|
+
- User can press `Ctrl+Enter` (or `Cmd+Enter` on Mac) to save
|
|
581
|
+
- Works automatically — no extra code in your form
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
### Edge case 8 — useCallback is required in hooks
|
|
586
|
+
|
|
587
|
+
Always wrap hook return values in `useCallback`:
|
|
588
|
+
|
|
589
|
+
```tsx
|
|
590
|
+
return useCallback(
|
|
591
|
+
(mode, vendor) => { openForm(VendorForm, { mode, vendor }); },
|
|
592
|
+
[openForm],
|
|
593
|
+
);
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Without it, page re-renders create new function references and break dependent hooks.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
# PART B — Modal (Read-Only / Quick Dialog)
|
|
601
|
+
|
|
602
|
+
Use a modal when the user needs to **view something** or confirm a quick action — not a full form.
|
|
603
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
## Step 1 — Build the Modal Content Component
|
|
607
|
+
|
|
608
|
+
**File:** `src/routes/vendors/components/view-vendor-dialog.tsx` *(new file)*
|
|
609
|
+
|
|
610
|
+
### Required rules for every modal component
|
|
611
|
+
|
|
612
|
+
1. Must accept `close: () => void`
|
|
613
|
+
2. Wrap content in `<ModalCard>` (not SidePanelCard)
|
|
614
|
+
3. Call `close()` when done
|
|
615
|
+
|
|
616
|
+
```tsx
|
|
617
|
+
import { Btn, ModalCard } from "@/components/base";
|
|
618
|
+
import type { Vendor } from "../data/vendors";
|
|
619
|
+
|
|
620
|
+
type ViewVendorDialogProps = {
|
|
621
|
+
vendor: Vendor;
|
|
622
|
+
close: () => void; // REQUIRED
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
export function ViewVendorDialog({ vendor, close }: ViewVendorDialogProps) {
|
|
626
|
+
return (
|
|
627
|
+
<ModalCard
|
|
628
|
+
title={vendor.name}
|
|
629
|
+
description={`ID: ${vendor.id}`}
|
|
630
|
+
close={close}
|
|
631
|
+
width="min(32rem, 92vw)"
|
|
632
|
+
footer={
|
|
633
|
+
<div className="flex justify-end gap-2">
|
|
634
|
+
<Btn variant="outline" onClick={close}>Close</Btn>
|
|
635
|
+
</div>
|
|
636
|
+
}
|
|
637
|
+
>
|
|
638
|
+
<dl className="space-y-2 text-sm">
|
|
639
|
+
<div><dt className="font-medium">Email</dt><dd>{vendor.email}</dd></div>
|
|
640
|
+
<div><dt className="font-medium">Phone</dt><dd>{vendor.phone}</dd></div>
|
|
641
|
+
<div><dt className="font-medium">Status</dt><dd>{vendor.status}</dd></div>
|
|
642
|
+
</dl>
|
|
643
|
+
</ModalCard>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Modal vs SidePanelCard:**
|
|
649
|
+
|
|
650
|
+
| | SidePanelCard | ModalCard |
|
|
651
|
+
|--|---------------|-----------|
|
|
652
|
+
| Position | Slides from right/left | Centered on screen |
|
|
653
|
+
| Use for | Create / Edit forms | View / Confirm / Quick action |
|
|
654
|
+
| Close animation | Slide out | Fade out |
|
|
655
|
+
| `isDirty` guard | Yes (built-in) | No (pass `close` directly) |
|
|
656
|
+
| Body scroll lock | Yes | Yes |
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
660
|
+
## Step 2 — Create the Modal Hook
|
|
661
|
+
|
|
662
|
+
**File:** `src/routes/vendors/hooks/use-view-vendor-dialog.tsx` *(new file)*
|
|
663
|
+
|
|
664
|
+
For modals, use `useOverlay` directly (not `useFormPanel`):
|
|
665
|
+
|
|
666
|
+
```tsx
|
|
667
|
+
import { useCallback } from "react";
|
|
668
|
+
import { useOverlay } from "@/hooks";
|
|
669
|
+
import { ViewVendorDialog } from "../components/view-vendor-dialog";
|
|
670
|
+
import type { Vendor } from "../data/vendors";
|
|
671
|
+
|
|
672
|
+
export function useViewVendorDialog() {
|
|
673
|
+
const { openModal } = useOverlay();
|
|
674
|
+
|
|
675
|
+
return useCallback(
|
|
676
|
+
(vendor: Vendor) => {
|
|
677
|
+
openModal(({ close }) => (
|
|
678
|
+
<ViewVendorDialog vendor={vendor} close={close} />
|
|
679
|
+
));
|
|
680
|
+
},
|
|
681
|
+
[openModal],
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
## Step 3 — Call It From Your Page
|
|
689
|
+
|
|
690
|
+
```tsx
|
|
691
|
+
import { useViewVendorDialog } from "./hooks/use-view-vendor-dialog";
|
|
692
|
+
|
|
693
|
+
export default function VendorsPage() {
|
|
694
|
+
const openViewVendor = useViewVendorDialog();
|
|
695
|
+
|
|
696
|
+
const handleView = (vendor: Vendor) => {
|
|
697
|
+
openViewVendor(vendor); // passes vendor data into dialog
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
return (
|
|
701
|
+
<Btn onClick={() => handleView(VENDORS[0])}>View Vendor</Btn>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
## Step 4 — What Happens When You Click "View Vendor" (Open Flow)
|
|
709
|
+
|
|
710
|
+
```
|
|
711
|
+
STEP 4.1 User clicks "View Vendor"
|
|
712
|
+
→ openViewVendor(vendor) runs
|
|
713
|
+
|
|
714
|
+
STEP 4.2 useViewVendorDialog calls:
|
|
715
|
+
→ openModal(({ close }) => <ViewVendorDialog vendor={vendor} close={close} />)
|
|
716
|
+
|
|
717
|
+
STEP 4.3 useOverlay calls:
|
|
718
|
+
→ open("modal", contentFn, options)
|
|
719
|
+
|
|
720
|
+
STEP 4.4 AppProvider.open() runs:
|
|
721
|
+
→ creates UUID id
|
|
722
|
+
→ pushes { id, type: "modal", content, options } onto stack
|
|
723
|
+
→ re-renders
|
|
724
|
+
|
|
725
|
+
STEP 4.5 AppProvider renders:
|
|
726
|
+
→ <Modal isActive={true} onClose={() => close(id)}>
|
|
727
|
+
{contentFn({ close: () => close(id) })}
|
|
728
|
+
</Modal>
|
|
729
|
+
|
|
730
|
+
STEP 4.6 Modal mounts:
|
|
731
|
+
→ renders via Portal
|
|
732
|
+
→ sets body overflow = hidden
|
|
733
|
+
→ Backdrop fades in (centered dark overlay)
|
|
734
|
+
→ ViewVendorDialog renders inside with vendor data
|
|
735
|
+
|
|
736
|
+
DONE — modal is open, user sees vendor details
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
**Key difference from side panel:** Modal passes `close` directly from AppProvider (no slide animation step). Calling `close()` immediately removes from stack.
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Step 5 — How to Close the Modal (All Paths)
|
|
744
|
+
|
|
745
|
+
### Path 1 — Close button in footer
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
User clicks Close
|
|
749
|
+
→ close() called directly
|
|
750
|
+
→ AppProvider.close(id)
|
|
751
|
+
→ Modal unmounts immediately (fade out via Backdrop)
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Path 2 — X button on ModalCard
|
|
755
|
+
|
|
756
|
+
```
|
|
757
|
+
User clicks X
|
|
758
|
+
→ close() called directly (ModalCard passes close to X button)
|
|
759
|
+
→ AppProvider.close(id)
|
|
760
|
+
→ Modal unmounts
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Path 3 — Click backdrop
|
|
764
|
+
|
|
765
|
+
```
|
|
766
|
+
User clicks dark area outside ModalCard
|
|
767
|
+
→ Backdrop onClick
|
|
768
|
+
→ requestOverlayCloseWithConfirm()
|
|
769
|
+
→ for modals without isDirty registration → closes immediately
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### Path 4 — Press Escape
|
|
773
|
+
|
|
774
|
+
```
|
|
775
|
+
User presses Escape
|
|
776
|
+
→ useKeyboardShortcuts catches it
|
|
777
|
+
→ requestOverlayCloseWithConfirm()
|
|
778
|
+
→ closes modal (no dirty check unless you registered a form)
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## Step 6 — Edge Cases for Modals
|
|
784
|
+
|
|
785
|
+
### Edge case 1 — Modal on top of side panel
|
|
786
|
+
|
|
787
|
+
```
|
|
788
|
+
User has VendorForm open (side panel)
|
|
789
|
+
→ something calls openModal(...)
|
|
790
|
+
→ stack = [side-panel, modal]
|
|
791
|
+
→ modal is active, form is hidden behind it
|
|
792
|
+
→ closing modal reveals form again (still open, still dirty)
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Plan for this if your form opens confirmation modals.
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
### Edge case 2 — Do not use useFormPanel for modals
|
|
800
|
+
|
|
801
|
+
`useFormPanel` always opens a **side panel**. For modals, use `useOverlay().openModal` directly.
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
### Edge case 3 — Modal with a form inside
|
|
806
|
+
|
|
807
|
+
If your modal has editable fields and you want dirty-check:
|
|
808
|
+
|
|
809
|
+
- You can still use `SidePanelCard`-style registration, but modals typically use `ModalCard` without `isDirty`
|
|
810
|
+
- For forms with dirty-check, prefer a **side panel** instead
|
|
811
|
+
- Or call `requestOverlayCloseWithConfirm()` on cancel if you add custom dirty logic
|
|
812
|
+
|
|
813
|
+
---
|
|
814
|
+
|
|
815
|
+
### Edge case 4 — Passing different data each time
|
|
816
|
+
|
|
817
|
+
Each call to `openViewVendor(vendor)` creates a **new stack item** with a fresh UUID.
|
|
818
|
+
Old modals in the stack stay until explicitly closed.
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
# PART C — Quick Decision Guide
|
|
823
|
+
|
|
824
|
+
```
|
|
825
|
+
Need to CREATE or EDIT records?
|
|
826
|
+
→ Side Panel
|
|
827
|
+
→ useFormPanel + SidePanelCard
|
|
828
|
+
→ Domain hook: useVendorFormPanel
|
|
829
|
+
→ Page calls: openVendorForm("create") or openVendorForm("update", data)
|
|
830
|
+
|
|
831
|
+
Need to VIEW details or quick confirm?
|
|
832
|
+
→ Modal
|
|
833
|
+
→ useOverlay().openModal + ModalCard
|
|
834
|
+
→ Domain hook: useViewVendorDialog
|
|
835
|
+
→ Page calls: openViewVendor(data)
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
# PART D — File Checklist (Copy When Building New Feature)
|
|
841
|
+
|
|
842
|
+
For a new **side panel form** entity, create these files:
|
|
843
|
+
|
|
844
|
+
```
|
|
845
|
+
src/routes/vendors/
|
|
846
|
+
├── data/
|
|
847
|
+
│ ├── vendors.ts ← table/API type + mock data
|
|
848
|
+
│ └── vendor-form.ts ← form values + defaults + mapper
|
|
849
|
+
├── components/
|
|
850
|
+
│ └── vendor-form/
|
|
851
|
+
│ └── index.tsx ← VendorForm (SidePanelCard + fields)
|
|
852
|
+
├── hooks/
|
|
853
|
+
│ └── use-vendor-form-panel.tsx ← connects form to overlay
|
|
854
|
+
└── index.tsx ← page, calls openVendorForm()
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
For a new **modal dialog**, create:
|
|
858
|
+
|
|
859
|
+
```
|
|
860
|
+
src/routes/vendors/
|
|
861
|
+
├── components/
|
|
862
|
+
│ └── view-vendor-dialog.tsx ← ViewVendorDialog (ModalCard + content)
|
|
863
|
+
├── hooks/
|
|
864
|
+
│ └── use-view-vendor-dialog.tsx ← connects dialog to overlay
|
|
865
|
+
└── index.tsx ← page, calls openViewVendor()
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
# PART E — Common Mistakes
|
|
871
|
+
|
|
872
|
+
| Mistake | Result | Fix |
|
|
873
|
+
|---------|--------|-----|
|
|
874
|
+
| Render `<SidePanel>` in page directly | Double panels, no stack, broken z-index | Use `openSidePanel()` hook only |
|
|
875
|
+
| Forget `close` prop on form component | TypeScript error or runtime crash | Always add `close: () => void` to props |
|
|
876
|
+
| Call `close()` on Cancel button | Skips unsaved-changes guard | Use `requestOverlayCloseWithConfirm()` |
|
|
877
|
+
| Use `useFormPanel` for a modal | Opens side panel instead of modal | Use `openModal()` for modals |
|
|
878
|
+
| Skip `useCallback` in domain hook | Page re-renders break handlers | Wrap return in `useCallback` |
|
|
879
|
+
| Forget `isDirty` on SidePanelCard | User loses data silently on close | Pass `isDirty={useFormDirty(values)}` |
|
|
880
|
+
| Call `openVendorForm()` outside AppProvider | Hook throws error | Ensure component is inside `<AppProvider>` |
|
|
881
|
+
| Pass wrong data shape to form | Fields empty in edit mode | Check `vendorToFormValues()` mapping |
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
# PART F — Minimum Working Example (Side Panel Only)
|
|
886
|
+
|
|
887
|
+
If you want the shortest possible working version with no extra hooks:
|
|
888
|
+
|
|
889
|
+
```tsx
|
|
890
|
+
// In any page inside AppProvider:
|
|
891
|
+
import { useOverlay } from "@/hooks";
|
|
892
|
+
import { SidePanelCard, Btn } from "@/components/base";
|
|
893
|
+
|
|
894
|
+
function MyPage() {
|
|
895
|
+
const { openSidePanel } = useOverlay();
|
|
896
|
+
|
|
897
|
+
const handleOpen = () => {
|
|
898
|
+
openSidePanel(({ close }) => (
|
|
899
|
+
<SidePanelCard title="Hello" close={close} footer={<Btn onClick={close}>Done</Btn>}>
|
|
900
|
+
<p>Panel content here</p>
|
|
901
|
+
</SidePanelCard>
|
|
902
|
+
));
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
return <Btn onClick={handleOpen}>Open Panel</Btn>;
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
This works but **does not scale**. For real features, follow Part A (domain hook pattern).
|
|
910
|
+
|
|
911
|
+
---
|
|
912
|
+
|
|
913
|
+
*Manual for FleetNexa Frontend Web — demo entity: Vendor*
|