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,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
+ }