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,702 @@
|
|
|
1
|
+
# Panels Overlay Manager
|
|
2
|
+
|
|
3
|
+
A modular, stack-safe, and keyboard-friendly Side Panel and Modal manager for React. Supports overlay stacking, split context to prevent re-renders, and automatic keyboard shortcuts.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The One Golden Rule
|
|
8
|
+
|
|
9
|
+
> **Never render `<SidePanel>` or `<Modal>` directly inside a page.**
|
|
10
|
+
>
|
|
11
|
+
> Always trigger overlays through a hook. `PanelProvider` renders everything via a React Portal on top of your entire app — you never place overlay components inside your page tree.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Quick Start — Setup (Do This Once)
|
|
16
|
+
|
|
17
|
+
Wrap your root application in `PanelProvider` (in `src/main.tsx` or `src/App.tsx`):
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { PanelProvider } from "@/pejay-ui/panels";
|
|
21
|
+
|
|
22
|
+
export default function App() {
|
|
23
|
+
return (
|
|
24
|
+
<PanelProvider>
|
|
25
|
+
<YourApp />
|
|
26
|
+
</PanelProvider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **Important:** If `PanelProvider` is missing, **no overlay will ever appear**. Always do this step first.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## The 4 Overlay Types
|
|
36
|
+
|
|
37
|
+
There are 4 ways to open an overlay. Choose the one that fits your use case:
|
|
38
|
+
|
|
39
|
+
| Type | Hook | Card | Best for |
|
|
40
|
+
| :------------------ | :------------------------------ | :-------------- | :----------------------------------------------- |
|
|
41
|
+
| **Form Side Panel** | `useFormPanel()` | `SidePanelCard` | Create / Edit forms in a slide-in panel |
|
|
42
|
+
| **Form Modal** | `useModalForm()` | `ModalCard` | Create / Edit forms in a centered dialog |
|
|
43
|
+
| **Raw Side Panel** | `useOverlay().openRawSidePanel` | `SidePanelRaw` | Fully custom side panel — you control everything |
|
|
44
|
+
| **Raw Modal** | `useOverlay().openRawModal` | `ModalRaw` | Fully custom modal — you control everything |
|
|
45
|
+
|
|
46
|
+
See the `vendor-example/` folder for a working reference of all 4 types.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Pattern 1 — Form Side Panel (4-File Pattern)
|
|
51
|
+
|
|
52
|
+
Use this for create/edit forms. See `vendor-example/by-using-sidepanel/` for the full working example.
|
|
53
|
+
|
|
54
|
+
### File 1 — Types (`your-types.ts`)
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// The entity model (from your API / table row)
|
|
58
|
+
export type Vendor = {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
email: string;
|
|
62
|
+
phone: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// The form field values (can differ from entity shape)
|
|
66
|
+
export type VendorFormValues = {
|
|
67
|
+
vendorName: string;
|
|
68
|
+
vendorEmail: string;
|
|
69
|
+
vendorPhone: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function getDefaultVendorFormValues(): VendorFormValues {
|
|
73
|
+
return { vendorName: "", vendorEmail: "", vendorPhone: "" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function vendorToFormValues(vendor: Vendor): VendorFormValues {
|
|
77
|
+
return {
|
|
78
|
+
vendorName: vendor.name,
|
|
79
|
+
vendorEmail: vendor.email,
|
|
80
|
+
vendorPhone: vendor.phone,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### File 2 — Form Component (`your-form.tsx`)
|
|
86
|
+
|
|
87
|
+
Rules every form overlay component **must** follow:
|
|
88
|
+
|
|
89
|
+
1. Accept `close: () => void` — injected automatically by the hook.
|
|
90
|
+
2. Wrap content in `<SidePanelCard>`.
|
|
91
|
+
3. Call `close()` only after a successful save.
|
|
92
|
+
4. Use `requestOverlayCloseWithConfirm` on Cancel so the dirty guard works.
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { useState } from "react";
|
|
96
|
+
import {
|
|
97
|
+
SidePanelCard,
|
|
98
|
+
useFormDirty,
|
|
99
|
+
requestOverlayCloseWithConfirm,
|
|
100
|
+
} from "@/pejay-ui/panels";
|
|
101
|
+
import type { Vendor } from "./your-types";
|
|
102
|
+
import { getDefaultVendorFormValues, vendorToFormValues } from "./your-types";
|
|
103
|
+
|
|
104
|
+
export type VendorFormMode = "create" | "update";
|
|
105
|
+
export type VendorFormProps = {
|
|
106
|
+
close: () => void; // REQUIRED — always accept this
|
|
107
|
+
mode?: VendorFormMode;
|
|
108
|
+
vendor?: Vendor;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function VendorForm({
|
|
112
|
+
close,
|
|
113
|
+
mode = "create",
|
|
114
|
+
vendor,
|
|
115
|
+
}: VendorFormProps) {
|
|
116
|
+
const [values, setValues] = useState(() => ({
|
|
117
|
+
...getDefaultVendorFormValues(),
|
|
118
|
+
...(vendor ? vendorToFormValues(vendor) : {}),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const isDirty = useFormDirty(values);
|
|
122
|
+
|
|
123
|
+
const handleSave = () => {
|
|
124
|
+
if (!values.vendorName.trim()) return alert("Name required");
|
|
125
|
+
console.log("Saving:", values);
|
|
126
|
+
close();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<SidePanelCard
|
|
131
|
+
title={mode === "update" ? "Edit Vendor" : "Add Vendor"}
|
|
132
|
+
close={close}
|
|
133
|
+
onSubmit={handleSave} // enables Ctrl+Enter
|
|
134
|
+
isDirty={isDirty} // enables unsaved-changes guard
|
|
135
|
+
size="md"
|
|
136
|
+
footer={
|
|
137
|
+
<div className="flex justify-end gap-3">
|
|
138
|
+
<button onClick={requestOverlayCloseWithConfirm}>Cancel</button>
|
|
139
|
+
<button onClick={handleSave}>Save</button>
|
|
140
|
+
</div>
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
<input
|
|
144
|
+
value={values.vendorName}
|
|
145
|
+
onChange={e => setValues(p => ({ ...p, vendorName: e.target.value }))}
|
|
146
|
+
placeholder="Vendor Name"
|
|
147
|
+
/>
|
|
148
|
+
</SidePanelCard>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### File 3 — Domain Hook (`useYourFormPanel.tsx`)
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { useCallback } from "react";
|
|
157
|
+
import { useFormPanel } from "@/pejay-ui/panels";
|
|
158
|
+
import { VendorForm, type VendorFormMode } from "./your-form";
|
|
159
|
+
import type { Vendor } from "./your-types";
|
|
160
|
+
|
|
161
|
+
export function useVendorFormPanel() {
|
|
162
|
+
const openForm = useFormPanel(); // <-- opens as SIDE PANEL
|
|
163
|
+
|
|
164
|
+
return useCallback(
|
|
165
|
+
(mode: VendorFormMode = "create", vendor?: Vendor) => {
|
|
166
|
+
openForm(VendorForm, { mode, vendor });
|
|
167
|
+
},
|
|
168
|
+
[openForm],
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### File 4 — Page Component (`YourPage.tsx`)
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
import { useVendorFormPanel } from "./useVendorFormPanel";
|
|
177
|
+
|
|
178
|
+
export default function VendorsPage() {
|
|
179
|
+
const openVendorForm = useVendorFormPanel();
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
<button onClick={() => openVendorForm("create")}>Add Vendor</button>
|
|
184
|
+
<button onClick={() => openVendorForm("update", selectedVendor)}>
|
|
185
|
+
Edit Vendor
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Pattern 2 — Form Modal (4-File Pattern)
|
|
195
|
+
|
|
196
|
+
Same form, same data — but opens in a **centered modal dialog** instead of a side panel. Only Files 2, 3, and 4 change. See `vendor-example/by-using-modal/` for the full working example.
|
|
197
|
+
|
|
198
|
+
### File 1 — Types (`your-types.ts`)
|
|
199
|
+
|
|
200
|
+
Identical to Pattern 1. The same type definitions work for both side panels and modals.
|
|
201
|
+
|
|
202
|
+
### File 2 — Form Component (`your-modal-form.tsx`)
|
|
203
|
+
|
|
204
|
+
The only difference from Pattern 1 is the wrapper: use `<ModalCard>` instead of `<SidePanelCard>`. All props are identical.
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
import { useState } from "react";
|
|
208
|
+
import {
|
|
209
|
+
ModalCard,
|
|
210
|
+
useFormDirty,
|
|
211
|
+
requestOverlayCloseWithConfirm,
|
|
212
|
+
} from "@/pejay-ui/panels";
|
|
213
|
+
import type { Vendor } from "./your-types";
|
|
214
|
+
import { getDefaultVendorFormValues, vendorToFormValues } from "./your-types";
|
|
215
|
+
|
|
216
|
+
export type VendorFormMode = "create" | "update";
|
|
217
|
+
export type VendorModalFormProps = {
|
|
218
|
+
close: () => void;
|
|
219
|
+
mode?: VendorFormMode;
|
|
220
|
+
vendor?: Vendor;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export function VendorModalForm({
|
|
224
|
+
close,
|
|
225
|
+
mode = "create",
|
|
226
|
+
vendor,
|
|
227
|
+
}: VendorModalFormProps) {
|
|
228
|
+
const [values, setValues] = useState(() => ({
|
|
229
|
+
...getDefaultVendorFormValues(),
|
|
230
|
+
...(vendor ? vendorToFormValues(vendor) : {}),
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
const isDirty = useFormDirty(values);
|
|
234
|
+
|
|
235
|
+
const handleSave = () => {
|
|
236
|
+
if (!values.vendorName.trim()) return alert("Name required");
|
|
237
|
+
console.log("Saving:", values);
|
|
238
|
+
close();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<ModalCard
|
|
243
|
+
title={mode === "update" ? "Edit Vendor" : "Add Vendor"}
|
|
244
|
+
description="Fill in the vendor details below."
|
|
245
|
+
close={close}
|
|
246
|
+
onSubmit={handleSave} // enables Ctrl+Enter
|
|
247
|
+
isDirty={isDirty} // enables unsaved-changes guard
|
|
248
|
+
size="md"
|
|
249
|
+
footer={
|
|
250
|
+
<div className="flex justify-end gap-3">
|
|
251
|
+
<button onClick={requestOverlayCloseWithConfirm}>Cancel</button>
|
|
252
|
+
<button onClick={handleSave}>Save</button>
|
|
253
|
+
</div>
|
|
254
|
+
}
|
|
255
|
+
>
|
|
256
|
+
<input
|
|
257
|
+
value={values.vendorName}
|
|
258
|
+
onChange={e => setValues(p => ({ ...p, vendorName: e.target.value }))}
|
|
259
|
+
placeholder="Vendor Name"
|
|
260
|
+
/>
|
|
261
|
+
</ModalCard>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### File 3 — Domain Hook (`useYourModalForm.tsx`)
|
|
267
|
+
|
|
268
|
+
Use `useModalForm()` instead of `useFormPanel()` — that's the only change.
|
|
269
|
+
|
|
270
|
+
```tsx
|
|
271
|
+
import { useCallback } from "react";
|
|
272
|
+
import { useModalForm } from "@/pejay-ui/panels";
|
|
273
|
+
import { VendorModalForm, type VendorFormMode } from "./your-modal-form";
|
|
274
|
+
import type { Vendor } from "./your-types";
|
|
275
|
+
|
|
276
|
+
export function useVendorModalForm() {
|
|
277
|
+
const openForm = useModalForm(); // <-- opens as MODAL
|
|
278
|
+
|
|
279
|
+
return useCallback(
|
|
280
|
+
(mode: VendorFormMode = "create", vendor?: Vendor) => {
|
|
281
|
+
openForm(VendorModalForm, { mode, vendor });
|
|
282
|
+
},
|
|
283
|
+
[openForm],
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### File 4 — Page Component (`YourModalPage.tsx`)
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
import { useVendorModalForm } from "./useVendorModalForm";
|
|
292
|
+
|
|
293
|
+
export default function VendorModalPage() {
|
|
294
|
+
const openVendorModal = useVendorModalForm();
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div>
|
|
298
|
+
<button onClick={() => openVendorModal("create")}>
|
|
299
|
+
Add Vendor (Modal)
|
|
300
|
+
</button>
|
|
301
|
+
<button onClick={() => openVendorModal("update", selectedVendor)}>
|
|
302
|
+
Edit Vendor (Modal)
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Pattern 3 — Raw Side Panel (Custom Layout)
|
|
312
|
+
|
|
313
|
+
Use this when you need **100% custom content** in a side panel — no title, no X button, no footer. Your content receives a `{ close }` callback and is fully responsible for its own layout.
|
|
314
|
+
|
|
315
|
+
### Option A — Quick Inline (single page)
|
|
316
|
+
|
|
317
|
+
Call it directly inside the page. Good for a one-off panel that's only used in one place:
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
import { useOverlay } from "@/pejay-ui/panels";
|
|
321
|
+
|
|
322
|
+
function MyPage() {
|
|
323
|
+
const { openRawSidePanel } = useOverlay();
|
|
324
|
+
|
|
325
|
+
const handleOpen = () => {
|
|
326
|
+
openRawSidePanel(({ close }) => (
|
|
327
|
+
<div className="p-6 flex flex-col gap-4">
|
|
328
|
+
<h2 className="text-xl font-bold">Custom Side Panel</h2>
|
|
329
|
+
<p>You control the entire layout here.</p>
|
|
330
|
+
<button onClick={close}>Close Me</button>
|
|
331
|
+
</div>
|
|
332
|
+
));
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return <button onClick={handleOpen}>Open Custom Side Panel</button>;
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Option B — Domain Hook (reuse across pages)
|
|
340
|
+
|
|
341
|
+
Follow the same domain hook pattern as Patterns 1 & 2 if you need to open the same custom panel from multiple pages. Create 2 files:
|
|
342
|
+
|
|
343
|
+
**`my-custom-panel.tsx`** — your custom content component:
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
// Accepts close injected by the hook
|
|
347
|
+
export type MyCustomPanelProps = {
|
|
348
|
+
close: () => void;
|
|
349
|
+
someData?: string; // any extra props your panel needs
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export function MyCustomPanel({ close, someData }: MyCustomPanelProps) {
|
|
353
|
+
return (
|
|
354
|
+
<div className="p-6 flex flex-col gap-4">
|
|
355
|
+
<h2 className="text-xl font-bold">Custom Side Panel</h2>
|
|
356
|
+
<p>Data: {someData}</p>
|
|
357
|
+
<button onClick={close}>Close</button>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**`useMyCustomPanel.tsx`** — the domain hook:
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
import { useCallback } from "react";
|
|
367
|
+
import { useOverlay } from "@/pejay-ui/panels";
|
|
368
|
+
import { MyCustomPanel } from "./my-custom-panel";
|
|
369
|
+
|
|
370
|
+
export function useMyCustomPanel() {
|
|
371
|
+
const { openRawSidePanel } = useOverlay();
|
|
372
|
+
|
|
373
|
+
return useCallback(
|
|
374
|
+
(someData?: string) => {
|
|
375
|
+
openRawSidePanel(({ close }) => (
|
|
376
|
+
<MyCustomPanel close={close} someData={someData} />
|
|
377
|
+
));
|
|
378
|
+
},
|
|
379
|
+
[openRawSidePanel],
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Any page** — just call the hook:
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
import { useMyCustomPanel } from "./useMyCustomPanel";
|
|
388
|
+
|
|
389
|
+
function PageA() {
|
|
390
|
+
const openPanel = useMyCustomPanel();
|
|
391
|
+
return <button onClick={() => openPanel("hello")}>Open Panel</button>;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function PageB() {
|
|
395
|
+
const openPanel = useMyCustomPanel();
|
|
396
|
+
return <button onClick={() => openPanel("world")}>Open Panel</button>;
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Pattern 4 — Raw Modal (Custom Layout)
|
|
403
|
+
|
|
404
|
+
Same as Pattern 3, but opens a **centered modal** with no chrome. Follows the exact same two options.
|
|
405
|
+
|
|
406
|
+
### Option A — Quick Inline (single page)
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
import { useOverlay } from "@/pejay-ui/panels";
|
|
410
|
+
|
|
411
|
+
function MyPage() {
|
|
412
|
+
const { openRawModal } = useOverlay();
|
|
413
|
+
|
|
414
|
+
const handleOpen = () => {
|
|
415
|
+
openRawModal(({ close }) => (
|
|
416
|
+
<div className="p-6 flex flex-col gap-4">
|
|
417
|
+
<h2 className="text-xl font-bold">Custom Modal</h2>
|
|
418
|
+
<p>You control the entire layout here.</p>
|
|
419
|
+
<button onClick={close}>Dismiss</button>
|
|
420
|
+
</div>
|
|
421
|
+
));
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return <button onClick={handleOpen}>Open Custom Modal</button>;
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Option B — Domain Hook (reuse across pages)
|
|
429
|
+
|
|
430
|
+
**`my-custom-modal.tsx`** — your custom content component:
|
|
431
|
+
|
|
432
|
+
```tsx
|
|
433
|
+
export type MyCustomModalProps = {
|
|
434
|
+
close: () => void;
|
|
435
|
+
someData?: string;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
export function MyCustomModal({ close, someData }: MyCustomModalProps) {
|
|
439
|
+
return (
|
|
440
|
+
<div className="p-6 flex flex-col gap-4">
|
|
441
|
+
<h2 className="text-xl font-bold">Custom Modal</h2>
|
|
442
|
+
<p>Data: {someData}</p>
|
|
443
|
+
<button onClick={close}>Dismiss</button>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**`useMyCustomModal.tsx`** — the domain hook:
|
|
450
|
+
|
|
451
|
+
```tsx
|
|
452
|
+
import { useCallback } from "react";
|
|
453
|
+
import { useOverlay } from "@/pejay-ui/panels";
|
|
454
|
+
import { MyCustomModal } from "./my-custom-modal";
|
|
455
|
+
|
|
456
|
+
export function useMyCustomModal() {
|
|
457
|
+
const { openRawModal } = useOverlay();
|
|
458
|
+
|
|
459
|
+
return useCallback(
|
|
460
|
+
(someData?: string) => {
|
|
461
|
+
openRawModal(({ close }) => (
|
|
462
|
+
<MyCustomModal close={close} someData={someData} />
|
|
463
|
+
));
|
|
464
|
+
},
|
|
465
|
+
[openRawModal],
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**Any page** — just call the hook:
|
|
471
|
+
|
|
472
|
+
```tsx
|
|
473
|
+
import { useMyCustomModal } from "./useMyCustomModal";
|
|
474
|
+
|
|
475
|
+
function PageA() {
|
|
476
|
+
const openModal = useMyCustomModal();
|
|
477
|
+
return <button onClick={() => openModal("hello")}>Open Modal</button>;
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Working Examples
|
|
484
|
+
|
|
485
|
+
```text
|
|
486
|
+
panels/
|
|
487
|
+
└── vendor-example/
|
|
488
|
+
├── by-using-sidepanel/ ← Pattern 1 (Form Side Panel)
|
|
489
|
+
│ ├── vendor-types.ts
|
|
490
|
+
│ ├── vendor-form.tsx ← Uses SidePanelCard
|
|
491
|
+
│ ├── useVendorFormPanel.tsx ← Uses useFormPanel()
|
|
492
|
+
│ └── VendorsPage.tsx
|
|
493
|
+
└── by-using-modal/ ← Pattern 2 (Form Modal)
|
|
494
|
+
├── vendor-types.ts
|
|
495
|
+
├── vendor-modal-form.tsx ← Uses ModalCard
|
|
496
|
+
├── useVendorModalForm.tsx ← Uses useModalForm()
|
|
497
|
+
└── VendorModalPage.tsx
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
For **Patterns 3 & 4** (Raw Wrappers), you have two options:
|
|
501
|
+
|
|
502
|
+
- **Option A (Inline):** Best for one-off panels used in a single page.
|
|
503
|
+
- **Option B (Domain Hook):** Best when the same custom panel is reused across multiple pages.
|
|
504
|
+
|
|
505
|
+
Both options follow the same two-file structure shown above, except they do **not** require a `vendor-types.ts` file.
|
|
506
|
+
|
|
507
|
+
See **`COMPONENTS.md`** for the complete prop reference for all four built-in card types.
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Keyboard Shortcuts
|
|
512
|
+
|
|
513
|
+
The following shortcuts are automatically available whenever a panel or modal is open:
|
|
514
|
+
|
|
515
|
+
| Shortcut | Action |
|
|
516
|
+
| :--------------------------- | :----------------------------------------- |
|
|
517
|
+
| `Esc` | Close the topmost panel or modal |
|
|
518
|
+
| `Ctrl + Enter` / `⌘ + Enter` | Submit the active form (calls `onSubmit`) |
|
|
519
|
+
| `Alt + ← / →` | Switch between tabs in a tabbed form panel |
|
|
520
|
+
| `Shift + ?` | Open the keyboard shortcuts help dialog |
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Advanced — Creating a Custom Card Type
|
|
525
|
+
|
|
526
|
+
The four built-in card types (`SidePanelCard`, `ModalCard`, `SidePanelRaw`, and `ModalRaw`) cover most use cases. If you need a completely different presentation—such as a **bottom drawer**, **fullscreen overlay**, **toast-style notification panel**, or any other custom container—you can register your own card type in four steps.
|
|
527
|
+
|
|
528
|
+
> **Note:** This requires modifying `PanelProvider.tsx` and `constants.ts` inside your project's installed `pejay-ui/panels/` directory.
|
|
529
|
+
|
|
530
|
+
---
|
|
531
|
+
|
|
532
|
+
### Step 1 — Register a New Type (`constants.ts`)
|
|
533
|
+
|
|
534
|
+
Add a new entry to the `APP_PROVIDER_TYPE` constant:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// src/pejay-ui/panels/core/constants.ts
|
|
538
|
+
export const APP_PROVIDER_TYPE = {
|
|
539
|
+
MODAL: "modal",
|
|
540
|
+
SIDE_PANEL: "side-panel",
|
|
541
|
+
MODAL_RAW: "modal-raw",
|
|
542
|
+
SIDE_PANEL_RAW: "side-panel-raw",
|
|
543
|
+
BOTTOM_DRAWER: "bottom-drawer", // Your custom type
|
|
544
|
+
} as const;
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Step 2 — Build your card component
|
|
548
|
+
|
|
549
|
+
Create your card however you like. The only contract is that it receives `children` and whatever extra props you need. There are no rules on layout:
|
|
550
|
+
|
|
551
|
+
```tsx
|
|
552
|
+
// src/pejay-ui/panels/components/bottom-drawer/bottom-drawer-card.tsx
|
|
553
|
+
import type { ReactNode } from "react";
|
|
554
|
+
|
|
555
|
+
interface BottomDrawerCardProps {
|
|
556
|
+
children: ReactNode;
|
|
557
|
+
close: () => void;
|
|
558
|
+
title?: string;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function BottomDrawerCard({
|
|
562
|
+
children,
|
|
563
|
+
close,
|
|
564
|
+
title,
|
|
565
|
+
}: BottomDrawerCardProps) {
|
|
566
|
+
return (
|
|
567
|
+
<div className="fixed bottom-0 left-0 right-0 bg-white rounded-t-2xl shadow-2xl p-6 max-h-[80vh] overflow-y-auto">
|
|
568
|
+
<div className="flex justify-between items-center mb-4">
|
|
569
|
+
{title && <h2 className="text-lg font-bold">{title}</h2>}
|
|
570
|
+
<button onClick={close} className="ml-auto">
|
|
571
|
+
✕
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
574
|
+
{children}
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
### Step 3 — Register it in `PanelProvider.tsx`
|
|
583
|
+
|
|
584
|
+
Import your new card and add a new `if` block inside the `stack.map()` renderer:
|
|
585
|
+
|
|
586
|
+
```tsx
|
|
587
|
+
// src/pejay-ui/panels/core/PanelProvider.tsx
|
|
588
|
+
import { BottomDrawerCard } from "../components/bottom-drawer/bottom-drawer-card";
|
|
589
|
+
import { Backdrop } from "../components/modal/backdrop"; // reuse existing backdrop
|
|
590
|
+
|
|
591
|
+
// Inside stack.map((item, index) => { ... }):
|
|
592
|
+
|
|
593
|
+
/* ── Bottom Drawer ── */
|
|
594
|
+
if (item.type === APP_PROVIDER_TYPE.BOTTOM_DRAWER) {
|
|
595
|
+
const closeId = () => close(item.id);
|
|
596
|
+
return (
|
|
597
|
+
<div
|
|
598
|
+
key={item.id}
|
|
599
|
+
style={{ position: "fixed", inset: 0, zIndex: getOverlayContentZ(layer) }}
|
|
600
|
+
>
|
|
601
|
+
<Backdrop onClick={closeId} layer={layer} />
|
|
602
|
+
<BottomDrawerCard close={closeId} title={item.options?.title as string}>
|
|
603
|
+
{item.content?.({ close: closeId })}
|
|
604
|
+
</BottomDrawerCard>
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
> **Tip:** You can reuse the existing `<Backdrop>`, `<SidePanel>`, or `<Modal>` shell components as outer containers and only swap out the inner card — this gives you the backdrop + animation for free.
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
### Step 4 — Create an `openBottomDrawer` hook
|
|
615
|
+
|
|
616
|
+
Use `usePanelOverlayActions()` to access the raw `open` primitive and wrap it in a clean hook:
|
|
617
|
+
|
|
618
|
+
```tsx
|
|
619
|
+
// src/pejay-ui/panels/hooks/useBottomDrawer.tsx
|
|
620
|
+
import { useCallback } from "react";
|
|
621
|
+
import { usePanelOverlayActions } from "../core/panel-context";
|
|
622
|
+
import { APP_PROVIDER_TYPE } from "../core/constants";
|
|
623
|
+
import type { OverlayContent } from "../core/types";
|
|
624
|
+
|
|
625
|
+
export function useBottomDrawer() {
|
|
626
|
+
const { open } = usePanelOverlayActions();
|
|
627
|
+
|
|
628
|
+
return useCallback(
|
|
629
|
+
(content: OverlayContent, title?: string) => {
|
|
630
|
+
open(APP_PROVIDER_TYPE.BOTTOM_DRAWER, content, { title });
|
|
631
|
+
},
|
|
632
|
+
[open],
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
### Using it (same domain hook pattern as all other types)
|
|
640
|
+
|
|
641
|
+
**Option A — Inline:**
|
|
642
|
+
|
|
643
|
+
```tsx
|
|
644
|
+
import { useBottomDrawer } from "@/pejay-ui/panels/hooks/useBottomDrawer";
|
|
645
|
+
|
|
646
|
+
function MyPage() {
|
|
647
|
+
const openDrawer = useBottomDrawer();
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<button
|
|
651
|
+
onClick={() =>
|
|
652
|
+
openDrawer(
|
|
653
|
+
({ close }) => (
|
|
654
|
+
<div>
|
|
655
|
+
<p>Bottom drawer content</p>
|
|
656
|
+
<button onClick={close}>Close</button>
|
|
657
|
+
</div>
|
|
658
|
+
),
|
|
659
|
+
"My Drawer Title",
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
>
|
|
663
|
+
Open Drawer
|
|
664
|
+
</button>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
**Option B — Domain hook for reuse:**
|
|
670
|
+
|
|
671
|
+
```tsx
|
|
672
|
+
// useMyDrawer.tsx
|
|
673
|
+
import { useCallback } from "react";
|
|
674
|
+
import { useBottomDrawer } from "@/pejay-ui/panels/hooks/useBottomDrawer";
|
|
675
|
+
import { MyDrawerContent } from "./my-drawer-content";
|
|
676
|
+
|
|
677
|
+
export function useMyDrawer() {
|
|
678
|
+
const openDrawer = useBottomDrawer();
|
|
679
|
+
|
|
680
|
+
return useCallback(
|
|
681
|
+
(data?: MyData) => {
|
|
682
|
+
openDrawer(
|
|
683
|
+
({ close }) => <MyDrawerContent close={close} data={data} />,
|
|
684
|
+
"My Drawer",
|
|
685
|
+
);
|
|
686
|
+
},
|
|
687
|
+
[openDrawer],
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
```tsx
|
|
693
|
+
// Any page
|
|
694
|
+
import { useMyDrawer } from "./useMyDrawer";
|
|
695
|
+
|
|
696
|
+
function PageA() {
|
|
697
|
+
const openDrawer = useMyDrawer();
|
|
698
|
+
return <button onClick={() => openDrawer(rowData)}>Open Drawer</button>;
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
> **Important:** Your custom hook (`useBottomDrawer`) lives inside your project's `pejay-ui/panels/hooks/` folder, not in the `@/pejay-ui/panels` barrel export by default. Either import it directly from its file path, or add it to `hooks/index.ts` so it's available from the main `@/pejay-ui/panels` import.
|