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,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
3
|
+
|
|
4
|
+
interface BaseCardProps {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
/** Override padding. Defaults to "p-2.5" */
|
|
7
|
+
padding?: string;
|
|
8
|
+
/** Override border radius. Defaults to "rounded-md" */
|
|
9
|
+
rounded?: string;
|
|
10
|
+
/** CSS height value e.g. "200px" | "50vh". min-h-0 is always applied. */
|
|
11
|
+
height?: string;
|
|
12
|
+
/** Override width. Defaults to "w-full" */
|
|
13
|
+
width?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function BaseCard({
|
|
18
|
+
children,
|
|
19
|
+
padding = "p-2.5",
|
|
20
|
+
rounded = "rounded-md",
|
|
21
|
+
height,
|
|
22
|
+
width = "w-full",
|
|
23
|
+
className,
|
|
24
|
+
}: BaseCardProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn("flex flex-row min-h-0", rounded, width, padding, className)}
|
|
28
|
+
style={height ? { height } : undefined}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./base-card";
|
|
2
|
+
export * from "./modal/modal";
|
|
3
|
+
export * from "./modal/modal-card";
|
|
4
|
+
export * from "./modal/modal-raw";
|
|
5
|
+
export * from "./modal/backdrop";
|
|
6
|
+
export * from "./side-panel/side-panel";
|
|
7
|
+
export * from "./side-panel/side-panel-card";
|
|
8
|
+
export * from "./side-panel/side-panel-raw";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { getOverlayBackdropZ } from "../../core/constants";
|
|
3
|
+
import { requestOverlayCloseWithConfirm } from "../../core/overlay-close";
|
|
4
|
+
|
|
5
|
+
interface BackdropProps {
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
onClose?: () => void;
|
|
8
|
+
justify?: string;
|
|
9
|
+
items?: string;
|
|
10
|
+
visible?: boolean;
|
|
11
|
+
isActive?: boolean;
|
|
12
|
+
layer?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Backdrop({
|
|
16
|
+
children,
|
|
17
|
+
onClose,
|
|
18
|
+
justify = "justify-center",
|
|
19
|
+
items = "items-center",
|
|
20
|
+
visible = true,
|
|
21
|
+
isActive = true,
|
|
22
|
+
layer = 1,
|
|
23
|
+
}: BackdropProps) {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isActive) return;
|
|
26
|
+
|
|
27
|
+
const originalOverflow = document.body.style.overflow;
|
|
28
|
+
document.body.style.overflow = "hidden";
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
document.body.style.overflow = originalOverflow;
|
|
32
|
+
};
|
|
33
|
+
}, [isActive]);
|
|
34
|
+
|
|
35
|
+
if (!isActive) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="pointer-events-none invisible fixed inset-0" aria-hidden>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<style>{`
|
|
46
|
+
@keyframes backdropFadeIn {
|
|
47
|
+
from {
|
|
48
|
+
opacity: 0;
|
|
49
|
+
backdrop-filter: blur(0px);
|
|
50
|
+
}
|
|
51
|
+
to {
|
|
52
|
+
opacity: 1;
|
|
53
|
+
backdrop-filter: blur(4px);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
@keyframes backdropFadeOut {
|
|
57
|
+
from {
|
|
58
|
+
opacity: 1;
|
|
59
|
+
backdrop-filter: blur(4px);
|
|
60
|
+
}
|
|
61
|
+
to {
|
|
62
|
+
opacity: 0;
|
|
63
|
+
backdrop-filter: blur(0px);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
.animate-backdrop-fade {
|
|
67
|
+
animation: backdropFadeIn 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
68
|
+
}
|
|
69
|
+
.animate-backdrop-fade-out {
|
|
70
|
+
animation: backdropFadeOut 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
71
|
+
}
|
|
72
|
+
`}</style>
|
|
73
|
+
|
|
74
|
+
<div
|
|
75
|
+
className={`fixed inset-0 overflow-hidden flex flex-row ${items} ${justify}`}
|
|
76
|
+
style={{ zIndex: getOverlayBackdropZ(layer) }}
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
onClick={() => requestOverlayCloseWithConfirm()}
|
|
80
|
+
className={`absolute inset-0 cursor-pointer bg-slate-900/20 ${
|
|
81
|
+
visible ? "animate-backdrop-fade" : "animate-backdrop-fade-out"
|
|
82
|
+
}`}
|
|
83
|
+
/>
|
|
84
|
+
<div className="relative z-10">{children}</div>
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
4
|
+
import type { FormTabsConfig } from "../../core/types";
|
|
5
|
+
import { requestOverlayCloseWithConfirm } from "../../core/overlay-close";
|
|
6
|
+
import { useFormOverlayRegistration } from "../../hooks/useFormOverlayRegistration";
|
|
7
|
+
|
|
8
|
+
export type ModalCardSize = "sm" | "md" | "lg" | "xl";
|
|
9
|
+
|
|
10
|
+
const MODAL_WIDTH: Record<ModalCardSize, string> = {
|
|
11
|
+
sm: "min(420px, 92vw)",
|
|
12
|
+
md: "min(560px, 92vw)",
|
|
13
|
+
lg: "min(720px, 92vw)",
|
|
14
|
+
xl: "min(900px, 92vw)",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface ModalCardProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
footer?: ReactNode;
|
|
23
|
+
close?: () => void;
|
|
24
|
+
/** Preset widths — use `width` to override entirely. */
|
|
25
|
+
size?: ModalCardSize;
|
|
26
|
+
/** Custom CSS width; overrides `size`. */
|
|
27
|
+
width?: string;
|
|
28
|
+
maxHeight?: string;
|
|
29
|
+
/** Renders below title row (e.g. tabs). */
|
|
30
|
+
headerSlot?: ReactNode;
|
|
31
|
+
bodyClassName?: string;
|
|
32
|
+
/** When provided, Ctrl+Enter triggers this handler. */
|
|
33
|
+
onSubmit?: () => void;
|
|
34
|
+
/** Enables unsaved-changes guard on close. */
|
|
35
|
+
isDirty?: boolean;
|
|
36
|
+
/** Blocks X button and Escape while an async action is in progress. */
|
|
37
|
+
closeDisabled?: boolean;
|
|
38
|
+
formTabs?: FormTabsConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ModalCard({
|
|
42
|
+
children,
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
footer,
|
|
46
|
+
className,
|
|
47
|
+
close,
|
|
48
|
+
size = "md",
|
|
49
|
+
width,
|
|
50
|
+
maxHeight = "min(90vh, 720px)",
|
|
51
|
+
headerSlot,
|
|
52
|
+
bodyClassName,
|
|
53
|
+
onSubmit,
|
|
54
|
+
isDirty = false,
|
|
55
|
+
closeDisabled = false,
|
|
56
|
+
formTabs,
|
|
57
|
+
}: ModalCardProps) {
|
|
58
|
+
const modalWidth = width ?? MODAL_WIDTH[size];
|
|
59
|
+
|
|
60
|
+
useFormOverlayRegistration({
|
|
61
|
+
enabled: Boolean(close),
|
|
62
|
+
onSubmit,
|
|
63
|
+
isDirty,
|
|
64
|
+
closeDisabled,
|
|
65
|
+
formTabs,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const handleClose = () => {
|
|
69
|
+
if (closeDisabled) return;
|
|
70
|
+
if (close) requestOverlayCloseWithConfirm();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex flex-col overflow-hidden rounded-xl bg-white shadow-2xl",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
style={{ width: modalWidth, maxHeight }}
|
|
80
|
+
onClick={(e) => e.stopPropagation()}
|
|
81
|
+
role="dialog"
|
|
82
|
+
aria-modal="true"
|
|
83
|
+
aria-labelledby={title ? "modal-title" : undefined}
|
|
84
|
+
>
|
|
85
|
+
{/* Header */}
|
|
86
|
+
<div className="shrink-0 border-b border-slate-200 px-6 pt-5 pb-0">
|
|
87
|
+
<div className="flex items-start justify-between gap-3 pb-4">
|
|
88
|
+
<div className="min-w-0">
|
|
89
|
+
{title && (
|
|
90
|
+
<h2
|
|
91
|
+
id="modal-title"
|
|
92
|
+
className="text-lg font-bold tracking-tight text-slate-900"
|
|
93
|
+
>
|
|
94
|
+
{title}
|
|
95
|
+
</h2>
|
|
96
|
+
)}
|
|
97
|
+
{description && (
|
|
98
|
+
<p className="mt-0.5 text-sm text-slate-500">{description}</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
{close && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={handleClose}
|
|
105
|
+
disabled={closeDisabled}
|
|
106
|
+
className={cn(
|
|
107
|
+
"shrink-0 rounded-full p-2 text-slate-400 transition-colors",
|
|
108
|
+
closeDisabled
|
|
109
|
+
? "cursor-not-allowed opacity-40"
|
|
110
|
+
: "cursor-pointer hover:bg-slate-100 hover:text-slate-600",
|
|
111
|
+
)}
|
|
112
|
+
aria-label="Close"
|
|
113
|
+
>
|
|
114
|
+
<X className="h-4 w-4" strokeWidth={2} />
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
{headerSlot}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Body */}
|
|
122
|
+
<div
|
|
123
|
+
className={cn(
|
|
124
|
+
"min-h-0 flex-1 overflow-y-auto px-6 py-5",
|
|
125
|
+
bodyClassName,
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Footer */}
|
|
132
|
+
{footer && (
|
|
133
|
+
<div className="shrink-0 border-t border-slate-200 bg-white px-6 py-4">
|
|
134
|
+
{footer}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
3
|
+
|
|
4
|
+
interface ModalRawProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
width?: string;
|
|
8
|
+
maxHeight?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A bare modal container with no title, no X button, and no footer.
|
|
13
|
+
* Use this when you want to render fully custom content inside a centered modal.
|
|
14
|
+
* Mount it via `openRawModal` from `useOverlay`.
|
|
15
|
+
*/
|
|
16
|
+
export function ModalRaw({
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
width = "min(560px, 92vw)",
|
|
20
|
+
maxHeight = "min(90vh, 720px)",
|
|
21
|
+
}: ModalRawProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className={cn(
|
|
25
|
+
"overflow-y-auto rounded-xl bg-white shadow-2xl p-2",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
style={{ width, maxHeight }}
|
|
29
|
+
onClick={(e) => e.stopPropagation()}
|
|
30
|
+
role="dialog"
|
|
31
|
+
aria-modal="true"
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import type { OverlayOptions } from "../../core/types";
|
|
3
|
+
import {
|
|
4
|
+
registerActiveOverlayClose,
|
|
5
|
+
unregisterActiveOverlayClose,
|
|
6
|
+
} from "../../core/form-overlay-registry";
|
|
7
|
+
import { Portal } from "@/pejay-ui/components/overlays";
|
|
8
|
+
import { Backdrop } from "./backdrop";
|
|
9
|
+
import { getOverlayContentZ } from "../../core/constants";
|
|
10
|
+
|
|
11
|
+
interface ModalBaseProps {
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
options: OverlayOptions;
|
|
15
|
+
isActive?: boolean;
|
|
16
|
+
layer?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Modal = ({
|
|
20
|
+
children,
|
|
21
|
+
onClose,
|
|
22
|
+
options,
|
|
23
|
+
isActive = true,
|
|
24
|
+
layer = 1,
|
|
25
|
+
}: ModalBaseProps) => {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!isActive || !onClose) return;
|
|
28
|
+
registerActiveOverlayClose(onClose);
|
|
29
|
+
return () => unregisterActiveOverlayClose(onClose);
|
|
30
|
+
}, [isActive, onClose]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Portal>
|
|
34
|
+
<Backdrop
|
|
35
|
+
{...options}
|
|
36
|
+
onClose={onClose}
|
|
37
|
+
isActive={isActive}
|
|
38
|
+
layer={layer}
|
|
39
|
+
>
|
|
40
|
+
<div
|
|
41
|
+
className="relative"
|
|
42
|
+
style={{ zIndex: getOverlayContentZ(layer) }}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
</Backdrop>
|
|
47
|
+
</Portal>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
4
|
+
import type { FormTabsConfig } from "../../core/types";
|
|
5
|
+
import { requestOverlayCloseWithConfirm } from "../../core/overlay-close";
|
|
6
|
+
import { useFormOverlayRegistration } from "../../hooks/useFormOverlayRegistration";
|
|
7
|
+
|
|
8
|
+
export type SidePanelSize = "sm" | "md" | "lg" | "xl";
|
|
9
|
+
|
|
10
|
+
const PANEL_WIDTH: Record<SidePanelSize, string> = {
|
|
11
|
+
sm: "min(480px, 100vw)",
|
|
12
|
+
md: "min(560px, 100vw)",
|
|
13
|
+
lg: "min(720px, 92vw)",
|
|
14
|
+
xl: "min(840px, 95vw)",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface SidePanelCardProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
close?: () => void;
|
|
23
|
+
footer?: ReactNode;
|
|
24
|
+
/** Preset widths — use `width` to override entirely. */
|
|
25
|
+
size?: SidePanelSize;
|
|
26
|
+
/** Custom CSS width; overrides `size`. */
|
|
27
|
+
width?: string;
|
|
28
|
+
/** Renders below title row (e.g. tabs). */
|
|
29
|
+
headerSlot?: ReactNode;
|
|
30
|
+
bodyClassName?: string;
|
|
31
|
+
onSubmit?: () => void;
|
|
32
|
+
isDirty?: boolean;
|
|
33
|
+
/** Blocks close via X, Escape, and backdrop while an async action is in progress. */
|
|
34
|
+
closeDisabled?: boolean;
|
|
35
|
+
formTabs?: FormTabsConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SidePanelCard({
|
|
39
|
+
children,
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
className,
|
|
43
|
+
close,
|
|
44
|
+
footer,
|
|
45
|
+
size = "sm",
|
|
46
|
+
width,
|
|
47
|
+
headerSlot,
|
|
48
|
+
bodyClassName,
|
|
49
|
+
onSubmit,
|
|
50
|
+
isDirty = false,
|
|
51
|
+
closeDisabled = false,
|
|
52
|
+
formTabs,
|
|
53
|
+
}: SidePanelCardProps) {
|
|
54
|
+
const panelWidth = width ?? PANEL_WIDTH[size];
|
|
55
|
+
|
|
56
|
+
useFormOverlayRegistration({
|
|
57
|
+
enabled: Boolean(close),
|
|
58
|
+
onSubmit,
|
|
59
|
+
isDirty,
|
|
60
|
+
closeDisabled,
|
|
61
|
+
formTabs,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const handleClose = () => {
|
|
65
|
+
if (closeDisabled) return;
|
|
66
|
+
requestOverlayCloseWithConfirm();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
"flex flex-col items-stretch h-screen max-w-full overflow-hidden border-l border-slate-200 bg-white shadow-2xl",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
style={{ width: panelWidth }}
|
|
76
|
+
>
|
|
77
|
+
<div className="w-full shrink-0 border-b border-slate-200 px-6 pt-4 pb-0">
|
|
78
|
+
<div className="flex flex-row items-center justify-between pb-4">
|
|
79
|
+
<div className="flex flex-col min-w-0 gap-0.5">
|
|
80
|
+
{title && (
|
|
81
|
+
<h2 className="text-lg font-bold tracking-tight text-slate-900">
|
|
82
|
+
{title}
|
|
83
|
+
</h2>
|
|
84
|
+
)}
|
|
85
|
+
{description && (
|
|
86
|
+
<p className="text-sm text-slate-500">{description}</p>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={handleClose}
|
|
92
|
+
disabled={closeDisabled}
|
|
93
|
+
className={cn(
|
|
94
|
+
"shrink-0 rounded-full p-2 text-slate-600 transition-colors",
|
|
95
|
+
closeDisabled
|
|
96
|
+
? "cursor-not-allowed opacity-40"
|
|
97
|
+
: "cursor-pointer hover:bg-slate-100 hover:text-slate-600",
|
|
98
|
+
)}
|
|
99
|
+
aria-label="Close panel"
|
|
100
|
+
>
|
|
101
|
+
<X className="h-4 w-4" strokeWidth={2} />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
{headerSlot}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div
|
|
108
|
+
className={cn(
|
|
109
|
+
"min-h-0 w-full flex-1 overflow-y-auto px-6 py-5",
|
|
110
|
+
bodyClassName,
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{footer && (
|
|
117
|
+
<div className="w-full shrink-0 border-t border-slate-200 bg-white px-6 py-4">
|
|
118
|
+
{footer}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
3
|
+
|
|
4
|
+
interface SidePanelRawProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A bare side panel container with no title, no X button, and no footer.
|
|
11
|
+
* Use this when you want to render fully custom content inside a side panel.
|
|
12
|
+
* Mount it via `openRawSidePanel` from `useOverlay`.
|
|
13
|
+
*/
|
|
14
|
+
export function SidePanelRaw({ children, className }: SidePanelRawProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={cn(
|
|
18
|
+
"flex h-screen max-w-full flex-col overflow-y-auto border-l border-slate-200 bg-white shadow-2xl p-2",
|
|
19
|
+
className,
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { motion, AnimatePresence } from "motion/react";
|
|
10
|
+
import { Portal } from "@/pejay-ui/components/overlays";
|
|
11
|
+
import type { OverlayContent, OverlayOptions } from "../../core/types";
|
|
12
|
+
import {
|
|
13
|
+
getOverlayBackdropZ,
|
|
14
|
+
getOverlayContentZ,
|
|
15
|
+
} from "../../core/constants";
|
|
16
|
+
import {
|
|
17
|
+
registerActiveOverlayClose,
|
|
18
|
+
unregisterActiveOverlayClose,
|
|
19
|
+
} from "../../core/form-overlay-registry";
|
|
20
|
+
import { requestOverlayCloseWithConfirm } from "../../core/overlay-close";
|
|
21
|
+
|
|
22
|
+
const panelSpring = { type: "spring" as const, damping: 25, stiffness: 200 };
|
|
23
|
+
|
|
24
|
+
const SidePanelCloseContext = createContext<(() => void) | null>(null);
|
|
25
|
+
|
|
26
|
+
/** Animated close — use this instead of AppProvider's immediate close. */
|
|
27
|
+
export function useAnimatedSidePanelClose() {
|
|
28
|
+
const close = useContext(SidePanelCloseContext);
|
|
29
|
+
if (!close) {
|
|
30
|
+
throw new Error("useAnimatedSidePanelClose must be used within SidePanel");
|
|
31
|
+
}
|
|
32
|
+
return close;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SidePanelBaseProps {
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
options: OverlayOptions;
|
|
39
|
+
isActive?: boolean;
|
|
40
|
+
layer?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SidePanel({
|
|
44
|
+
children,
|
|
45
|
+
onClose,
|
|
46
|
+
options,
|
|
47
|
+
isActive = true,
|
|
48
|
+
layer = 1,
|
|
49
|
+
}: SidePanelBaseProps) {
|
|
50
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
51
|
+
const isLeft = options?.onSide === "left";
|
|
52
|
+
|
|
53
|
+
const requestClose = useCallback(() => setIsOpen(false), []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isActive) return;
|
|
57
|
+
registerActiveOverlayClose(requestClose);
|
|
58
|
+
return () => unregisterActiveOverlayClose(requestClose);
|
|
59
|
+
}, [isActive, requestClose]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!isOpen || !isActive) return;
|
|
63
|
+
|
|
64
|
+
const originalOverflow = document.body.style.overflow;
|
|
65
|
+
document.body.style.overflow = "hidden";
|
|
66
|
+
return () => {
|
|
67
|
+
document.body.style.overflow = originalOverflow;
|
|
68
|
+
};
|
|
69
|
+
}, [isActive, isOpen]);
|
|
70
|
+
|
|
71
|
+
const offscreenX = isLeft ? "-100%" : "100%";
|
|
72
|
+
const panelPosition = isLeft ? "left-0" : "right-0";
|
|
73
|
+
const backdropZ = getOverlayBackdropZ(layer);
|
|
74
|
+
const panelZ = getOverlayContentZ(layer);
|
|
75
|
+
|
|
76
|
+
if (!isActive) {
|
|
77
|
+
return (
|
|
78
|
+
<Portal>
|
|
79
|
+
<div
|
|
80
|
+
className="pointer-events-none invisible fixed inset-0"
|
|
81
|
+
aria-hidden
|
|
82
|
+
>
|
|
83
|
+
<SidePanelCloseContext.Provider value={requestClose}>
|
|
84
|
+
{children}
|
|
85
|
+
</SidePanelCloseContext.Provider>
|
|
86
|
+
</div>
|
|
87
|
+
</Portal>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Portal>
|
|
93
|
+
<SidePanelCloseContext.Provider value={requestClose}>
|
|
94
|
+
<AnimatePresence onExitComplete={() => onClose?.()}>
|
|
95
|
+
{isOpen && (
|
|
96
|
+
<>
|
|
97
|
+
<motion.div
|
|
98
|
+
key="side-panel-backdrop"
|
|
99
|
+
initial={{ opacity: 0 }}
|
|
100
|
+
animate={{ opacity: 1 }}
|
|
101
|
+
exit={{ opacity: 0 }}
|
|
102
|
+
transition={{ duration: 0.2 }}
|
|
103
|
+
className="fixed inset-0 bg-slate-900/20 backdrop-blur-sm"
|
|
104
|
+
style={{ zIndex: backdropZ }}
|
|
105
|
+
onClick={requestOverlayCloseWithConfirm}
|
|
106
|
+
aria-hidden
|
|
107
|
+
/>
|
|
108
|
+
<motion.div
|
|
109
|
+
key="side-panel-panel"
|
|
110
|
+
initial={{ x: offscreenX }}
|
|
111
|
+
animate={{ x: 0 }}
|
|
112
|
+
exit={{ x: offscreenX }}
|
|
113
|
+
transition={panelSpring}
|
|
114
|
+
className={`fixed inset-y-0 flex h-full ${panelPosition}`}
|
|
115
|
+
style={{ zIndex: panelZ }}
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</motion.div>
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
</AnimatePresence>
|
|
122
|
+
</SidePanelCloseContext.Provider>
|
|
123
|
+
</Portal>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type SidePanelContentProps = {
|
|
128
|
+
content?: OverlayContent;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Renders provider content with animated `close` wired to the panel. */
|
|
132
|
+
export function SidePanelContent({ content }: SidePanelContentProps) {
|
|
133
|
+
const requestClose = useAnimatedSidePanelClose();
|
|
134
|
+
return content?.({ close: requestClose }) ?? null;
|
|
135
|
+
}
|