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.
Files changed (148) hide show
  1. package/README.md +26 -0
  2. package/bin/cli.js +45 -15
  3. package/package.json +2 -1
  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,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*