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,145 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
+ import {
3
+ PanelOverlayStateContext,
4
+ PanelOverlayActionsContext,
5
+ } from "./panel-context";
6
+ import { APP_PROVIDER_TYPE } from "./constants";
7
+ import { useHotkey } from "@/pejay-ui/hotkeys";
8
+ import { requestOverlayCloseWithConfirm } from "./overlay-close";
9
+ import type { OverlayStackItem, OverlayContent, OverlayOptions } from "./types";
10
+ import { Modal } from "../components/modal/modal";
11
+ import {
12
+ SidePanel,
13
+ SidePanelContent,
14
+ } from "../components/side-panel/side-panel";
15
+ import { ModalRaw } from "../components/modal/modal-raw";
16
+ import { SidePanelRaw } from "../components/side-panel/side-panel-raw";
17
+
18
+ export interface PanelProviderProps {
19
+ children: ReactNode;
20
+ }
21
+
22
+ export const PanelProvider = ({ children }: PanelProviderProps) => {
23
+ const [stack, setStack] = useState<OverlayStackItem[]>([]);
24
+
25
+ const open = useCallback(
26
+ (type: string, content?: OverlayContent, options?: OverlayOptions) => {
27
+ const id = crypto.randomUUID();
28
+ setStack((prev) => [...prev, { id, type, content, options }]);
29
+ },
30
+ [],
31
+ );
32
+
33
+ const close = useCallback((id: string) => {
34
+ setStack((prev) => prev.filter((item) => item.id !== id));
35
+ }, []);
36
+
37
+ const isOverlayOpen = stack.length > 0;
38
+ const stackDepth = stack.length;
39
+
40
+ useHotkey(
41
+ "escape",
42
+ () => {
43
+ requestOverlayCloseWithConfirm();
44
+ },
45
+ {
46
+ category: "Overlays",
47
+ description: "Close topmost active modal or side panel",
48
+ disabled: !isOverlayOpen,
49
+ enabledInInputs: true,
50
+ },
51
+ );
52
+
53
+ const stateValue = useMemo(
54
+ () => ({ stack, isOverlayOpen, stackDepth }),
55
+ [stack, isOverlayOpen, stackDepth],
56
+ );
57
+
58
+ const actionsValue = useMemo(
59
+ () => ({ open, close }),
60
+ [open, close],
61
+ );
62
+
63
+ const topOverlayId = stack.at(-1)?.id;
64
+
65
+ return (
66
+ <PanelOverlayStateContext.Provider value={stateValue}>
67
+ <PanelOverlayActionsContext.Provider value={actionsValue}>
68
+ {children}
69
+
70
+ {stack.map((item, index) => {
71
+ const isActive = item.id === topOverlayId;
72
+ const layer = index + 1;
73
+ const onClose = () => close(item.id);
74
+ const closeId = () => close(item.id);
75
+
76
+ /* ── Form Side Panel ── */
77
+ if (item.type === APP_PROVIDER_TYPE.SIDE_PANEL) {
78
+ return (
79
+ <SidePanel
80
+ key={item.id}
81
+ options={item.options || {}}
82
+ isActive={isActive}
83
+ layer={layer}
84
+ onClose={onClose}
85
+ >
86
+ <SidePanelContent content={item.content} />
87
+ </SidePanel>
88
+ );
89
+ }
90
+
91
+ /* ── Form Modal ── */
92
+ if (item.type === APP_PROVIDER_TYPE.MODAL) {
93
+ return (
94
+ <Modal
95
+ key={item.id}
96
+ options={item.options || {}}
97
+ isActive={isActive}
98
+ layer={layer}
99
+ onClose={onClose}
100
+ >
101
+ {item.content?.({ close: closeId })}
102
+ </Modal>
103
+ );
104
+ }
105
+
106
+ /* ── Raw Side Panel (no chrome) ── */
107
+ if (item.type === APP_PROVIDER_TYPE.SIDE_PANEL_RAW) {
108
+ return (
109
+ <SidePanel
110
+ key={item.id}
111
+ options={item.options || {}}
112
+ isActive={isActive}
113
+ layer={layer}
114
+ onClose={onClose}
115
+ >
116
+ <SidePanelRaw>
117
+ {item.content?.({ close: closeId })}
118
+ </SidePanelRaw>
119
+ </SidePanel>
120
+ );
121
+ }
122
+
123
+ /* ── Raw Modal (no chrome) ── */
124
+ if (item.type === APP_PROVIDER_TYPE.MODAL_RAW) {
125
+ return (
126
+ <Modal
127
+ key={item.id}
128
+ options={item.options || {}}
129
+ isActive={isActive}
130
+ layer={layer}
131
+ onClose={onClose}
132
+ >
133
+ <ModalRaw>
134
+ {item.content?.({ close: closeId })}
135
+ </ModalRaw>
136
+ </Modal>
137
+ );
138
+ }
139
+
140
+ return null;
141
+ })}
142
+ </PanelOverlayActionsContext.Provider>
143
+ </PanelOverlayStateContext.Provider>
144
+ );
145
+ };
@@ -0,0 +1,9 @@
1
+ export const APP_PROVIDER_TYPE = {
2
+ MODAL: "modal",
3
+ SIDE_PANEL: "side-panel",
4
+ MODAL_RAW: "modal-raw",
5
+ SIDE_PANEL_RAW: "side-panel-raw",
6
+ } as const;
7
+
8
+ export const getOverlayBackdropZ = (layer: number) => 1000 + layer * 10;
9
+ export const getOverlayContentZ = (layer: number) => 1000 + layer * 10 + 1;
@@ -0,0 +1,35 @@
1
+ import type { FormOverlayRegistration } from "./types";
2
+
3
+ let closeStack: (() => void)[] = [];
4
+ let formStack: FormOverlayRegistration[] = [];
5
+
6
+ export function registerActiveOverlayClose(handler: () => void) {
7
+ closeStack.push(handler);
8
+ }
9
+
10
+ export function unregisterActiveOverlayClose(handler: (() => void) | null) {
11
+ if (!handler) return;
12
+ closeStack = closeStack.filter((h) => h !== handler);
13
+ }
14
+
15
+ export function getActiveOverlayClose() {
16
+ return closeStack[closeStack.length - 1] || null;
17
+ }
18
+
19
+ export function registerActiveFormOverlay(
20
+ registration: FormOverlayRegistration | null,
21
+ ) {
22
+ if (!registration) return;
23
+ formStack.push(registration);
24
+ }
25
+
26
+ export function unregisterActiveFormOverlay(
27
+ registration: FormOverlayRegistration | null,
28
+ ) {
29
+ if (!registration) return;
30
+ formStack = formStack.filter((item) => item !== registration);
31
+ }
32
+
33
+ export function getActiveFormOverlay() {
34
+ return formStack[formStack.length - 1] || null;
35
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./PanelProvider";
2
+ export * from "./panel-context";
3
+ export * from "./types";
4
+ export * from "./constants";
5
+ export * from "./form-overlay-registry";
6
+ export * from "./overlay-close";
@@ -0,0 +1,11 @@
1
+ import { getActiveFormOverlay, getActiveOverlayClose } from "./form-overlay-registry";
2
+
3
+ export function requestOverlayCloseWithConfirm() {
4
+ const form = getActiveFormOverlay();
5
+ if (form?.isCloseBlocked?.()) {
6
+ return;
7
+ }
8
+
9
+ const requestClose = getActiveOverlayClose();
10
+ requestClose?.();
11
+ }
@@ -0,0 +1,41 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { OverlayStackItem, OverlayContent, OverlayOptions } from "./types";
3
+
4
+ export interface PanelOverlayStateProps {
5
+ stack: OverlayStackItem[];
6
+ isOverlayOpen: boolean;
7
+ stackDepth: number;
8
+ }
9
+
10
+ export interface PanelOverlayActionsProps {
11
+ open: (
12
+ type: string,
13
+ content?: OverlayContent,
14
+ options?: OverlayOptions,
15
+ ) => void;
16
+ close: (id: string) => void;
17
+ }
18
+
19
+ export const PanelOverlayStateContext = createContext<
20
+ PanelOverlayStateProps | undefined
21
+ >(undefined);
22
+
23
+ export const PanelOverlayActionsContext = createContext<
24
+ PanelOverlayActionsProps | undefined
25
+ >(undefined);
26
+
27
+ export function usePanelOverlayState() {
28
+ const context = useContext(PanelOverlayStateContext);
29
+ if (context === undefined) {
30
+ throw new Error("usePanelOverlayState must be used within PanelProvider");
31
+ }
32
+ return context;
33
+ }
34
+
35
+ export function usePanelOverlayActions() {
36
+ const context = useContext(PanelOverlayActionsContext);
37
+ if (context === undefined) {
38
+ throw new Error("usePanelOverlayActions must be used within PanelProvider");
39
+ }
40
+ return context;
41
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type OverlayContent = (helpers: { close: () => void }) => ReactNode;
4
+
5
+ export interface OverlayOptions {
6
+ onSide?: "left" | "right";
7
+ width?: string;
8
+ [key: string]: any;
9
+ }
10
+
11
+ export interface OverlayStackItem {
12
+ id: string;
13
+ type: string;
14
+ content?: OverlayContent;
15
+ options?: OverlayOptions;
16
+ }
17
+
18
+ export interface AppContextProps {
19
+ open: (type: string, content?: OverlayContent, options?: OverlayOptions) => void;
20
+ close: (id: string) => void;
21
+ stack: OverlayStackItem[];
22
+ isOverlayOpen: boolean;
23
+ stackDepth: number;
24
+ }
25
+
26
+ export interface ProviderProps {
27
+ children: ReactNode;
28
+ }
29
+
30
+ export type FormTabsConfig = {
31
+ active: string;
32
+ setActive: (id: string) => void;
33
+ order: string[];
34
+ };
35
+
36
+ export type FormOverlayRegistration = {
37
+ onSubmit?: () => void;
38
+ isDirty?: () => boolean;
39
+ isCloseBlocked?: () => boolean;
40
+ tabs?: FormTabsConfig;
41
+ };
@@ -0,0 +1,7 @@
1
+ export * from "./useOverlay";
2
+ export * from "./useFormPanel";
3
+ export * from "./useModalForm";
4
+ export * from "./useFormOverlayRegistration";
5
+ export * from "./useFormDirty";
6
+ export * from "./useFormTabHandler";
7
+ export * from "./useHorizontalWheelScroll";
@@ -0,0 +1,6 @@
1
+ import { useRef } from "react";
2
+
3
+ export function useFormDirty<T>(values: T): boolean {
4
+ const initialRef = useRef(values);
5
+ return JSON.stringify(values) !== JSON.stringify(initialRef.current);
6
+ }
@@ -0,0 +1,92 @@
1
+ import { useEffect } from "react";
2
+ import { useHotkey } from "@/pejay-ui/hotkeys";
3
+ import {
4
+ registerActiveFormOverlay,
5
+ unregisterActiveFormOverlay,
6
+ } from "../core/form-overlay-registry";
7
+ import type { FormOverlayRegistration, FormTabsConfig } from "../core/types";
8
+
9
+ type UseFormOverlayRegistrationArgs = {
10
+ enabled?: boolean;
11
+ onSubmit?: () => void;
12
+ isDirty?: boolean;
13
+ closeDisabled?: boolean;
14
+ formTabs?: FormTabsConfig;
15
+ };
16
+
17
+ export function useFormOverlayRegistration({
18
+ enabled = true,
19
+ onSubmit,
20
+ isDirty = false,
21
+ closeDisabled = false,
22
+ formTabs,
23
+ }: UseFormOverlayRegistrationArgs) {
24
+ useEffect(() => {
25
+ if (!enabled) return;
26
+
27
+ const registration: FormOverlayRegistration = {
28
+ onSubmit,
29
+ isDirty: () => isDirty,
30
+ isCloseBlocked: () => closeDisabled,
31
+ tabs: formTabs,
32
+ };
33
+
34
+ registerActiveFormOverlay(registration);
35
+ return () => unregisterActiveFormOverlay(registration);
36
+ }, [
37
+ enabled,
38
+ onSubmit,
39
+ isDirty,
40
+ closeDisabled,
41
+ formTabs?.active,
42
+ formTabs?.order.join(","),
43
+ formTabs?.setActive,
44
+ ]);
45
+
46
+ // Dynamically register submitting key combo
47
+ useHotkey(
48
+ "ctrl+enter",
49
+ () => {
50
+ onSubmit?.();
51
+ },
52
+ {
53
+ category: "Form Actions",
54
+ description: "Submit and save current form",
55
+ disabled: !enabled || !onSubmit || closeDisabled,
56
+ },
57
+ );
58
+
59
+ const hasTabs = Boolean(formTabs && formTabs.order.length > 0);
60
+
61
+ // Dynamically register tab switching combos
62
+ useHotkey(
63
+ "alt+arrowleft",
64
+ () => {
65
+ if (!formTabs) return;
66
+ const currentIndex = formTabs.order.indexOf(formTabs.active);
67
+ const prevIndex =
68
+ (currentIndex - 1 + formTabs.order.length) % formTabs.order.length;
69
+ formTabs.setActive(formTabs.order[prevIndex]!);
70
+ },
71
+ {
72
+ category: "Form Navigation",
73
+ description: "Switch to previous form tab",
74
+ disabled: !enabled || !hasTabs || closeDisabled,
75
+ },
76
+ );
77
+
78
+ useHotkey(
79
+ "alt+arrowright",
80
+ () => {
81
+ if (!formTabs) return;
82
+ const currentIndex = formTabs.order.indexOf(formTabs.active);
83
+ const nextIndex = (currentIndex + 1) % formTabs.order.length;
84
+ formTabs.setActive(formTabs.order[nextIndex]!);
85
+ },
86
+ {
87
+ category: "Form Navigation",
88
+ description: "Switch to next form tab",
89
+ disabled: !enabled || !hasTabs || closeDisabled,
90
+ },
91
+ );
92
+ }
@@ -0,0 +1,18 @@
1
+ import { useCallback, type ComponentType } from "react";
2
+ import { useOverlay } from "./useOverlay";
3
+
4
+ export function useFormPanel() {
5
+ const { openSidePanel } = useOverlay();
6
+
7
+ return useCallback(
8
+ <P extends { close: () => void }>(
9
+ Component: ComponentType<P>,
10
+ props: Omit<P, "close">,
11
+ ) => {
12
+ openSidePanel(({ close }) => (
13
+ <Component {...({ ...props, close } as P)} />
14
+ ));
15
+ },
16
+ [openSidePanel],
17
+ );
18
+ }
@@ -0,0 +1,25 @@
1
+ import { useCallback, type Dispatch, type SetStateAction } from "react";
2
+ import type { FormTabsConfig } from "../core/types";
3
+
4
+ export function useFormTabHandler<T extends string>(
5
+ setActive: Dispatch<SetStateAction<T>>,
6
+ ) {
7
+ return useCallback(
8
+ (tab: T) => {
9
+ setActive(tab);
10
+ },
11
+ [setActive],
12
+ );
13
+ }
14
+
15
+ export function toFormTabsConfig<T extends string>(
16
+ active: T,
17
+ handleTabChange: (tab: T) => void,
18
+ order: T[],
19
+ ): FormTabsConfig {
20
+ return {
21
+ active,
22
+ setActive: (id) => handleTabChange(id as T),
23
+ order,
24
+ };
25
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ export function useHorizontalWheelScroll<T extends HTMLElement>() {
4
+ const ref = useRef<T>(null);
5
+
6
+ useEffect(() => {
7
+ const element = ref.current;
8
+ if (!element) return;
9
+
10
+ const onWheel = (event: WheelEvent) => {
11
+ const maxScrollLeft = element.scrollWidth - element.clientWidth;
12
+ if (maxScrollLeft <= 0) return;
13
+
14
+ // Preserve native horizontal trackpad / shift+wheel gestures.
15
+ if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
16
+
17
+ event.preventDefault();
18
+ event.stopPropagation();
19
+ element.scrollLeft += event.deltaY;
20
+ };
21
+
22
+ element.addEventListener("wheel", onWheel, {
23
+ passive: false,
24
+ capture: true,
25
+ });
26
+ return () =>
27
+ element.removeEventListener("wheel", onWheel, { capture: true });
28
+ }, []);
29
+
30
+ return ref;
31
+ }
@@ -0,0 +1,22 @@
1
+ import { useCallback, type ComponentType } from "react";
2
+ import { useOverlay } from "./useOverlay";
3
+
4
+ /**
5
+ * Like `useFormPanel` but opens the component inside a centered MODAL
6
+ * instead of a side panel. Injects `close` automatically.
7
+ */
8
+ export function useModalForm() {
9
+ const { openModal } = useOverlay();
10
+
11
+ return useCallback(
12
+ <P extends { close: () => void }>(
13
+ Component: ComponentType<P>,
14
+ props: Omit<P, "close">,
15
+ ) => {
16
+ openModal(({ close }) => (
17
+ <Component {...({ ...props, close } as P)} />
18
+ ));
19
+ },
20
+ [openModal],
21
+ );
22
+ }
@@ -0,0 +1,65 @@
1
+ import { useCallback } from "react";
2
+ import {
3
+ usePanelOverlayActions,
4
+ usePanelOverlayState,
5
+ } from "../core/panel-context";
6
+ import { APP_PROVIDER_TYPE } from "../core/constants";
7
+ import type { OverlayContent, OverlayOptions } from "../core/types";
8
+
9
+ export function useOverlay() {
10
+ const { open } = usePanelOverlayActions();
11
+ const { isOverlayOpen, stackDepth } = usePanelOverlayState();
12
+
13
+ /** Open a form side panel (with title, X, footer, keyboard shortcuts). */
14
+ const openSidePanel = useCallback(
15
+ (content: OverlayContent, options?: OverlayOptions) => {
16
+ open(APP_PROVIDER_TYPE.SIDE_PANEL, content, {
17
+ onSide: "right",
18
+ ...options,
19
+ });
20
+ },
21
+ [open],
22
+ );
23
+
24
+ /** Open a form modal (with title, X, footer, keyboard shortcuts). */
25
+ const openModal = useCallback(
26
+ (content: OverlayContent, options?: OverlayOptions) => {
27
+ open(APP_PROVIDER_TYPE.MODAL, content, options);
28
+ },
29
+ [open],
30
+ );
31
+
32
+ /**
33
+ * Open a raw side panel with no chrome (no title, X button, or footer).
34
+ * Your content receives `{ close }` and is responsible for its own close trigger.
35
+ */
36
+ const openRawSidePanel = useCallback(
37
+ (content: OverlayContent, options?: OverlayOptions) => {
38
+ open(APP_PROVIDER_TYPE.SIDE_PANEL_RAW, content, {
39
+ onSide: "right",
40
+ ...options,
41
+ });
42
+ },
43
+ [open],
44
+ );
45
+
46
+ /**
47
+ * Open a raw modal with no chrome (no title, X button, or footer).
48
+ * Your content receives `{ close }` and is responsible for its own close trigger.
49
+ */
50
+ const openRawModal = useCallback(
51
+ (content: OverlayContent, options?: OverlayOptions) => {
52
+ open(APP_PROVIDER_TYPE.MODAL_RAW, content, options);
53
+ },
54
+ [open],
55
+ );
56
+
57
+ return {
58
+ openSidePanel,
59
+ openModal,
60
+ openRawSidePanel,
61
+ openRawModal,
62
+ isOverlayOpen,
63
+ stackDepth,
64
+ };
65
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./core";
2
+ export * from "./hooks";
3
+ export * from "./components";
@@ -0,0 +1,47 @@
1
+ import { useVendorModalForm } from "./useVendorModalForm";
2
+ import type { Vendor } from "./vendor-types";
3
+
4
+ export default function VendorModalPage() {
5
+ const openVendorModal = useVendorModalForm();
6
+
7
+ const handleCreate = () => {
8
+ openVendorModal("create");
9
+ };
10
+
11
+ const handleEdit = (vendor: Vendor) => {
12
+ openVendorModal("update", vendor);
13
+ };
14
+
15
+ return (
16
+ <div className="p-6 max-w-xl mx-auto space-y-4">
17
+ <h1 className="text-xl font-bold text-slate-800">Vendors (Modal Demo)</h1>
18
+ <p className="text-sm text-slate-500">
19
+ Same Vendor form, same domain hook pattern — but opens in a centered modal instead of a side panel.
20
+ </p>
21
+
22
+ <div className="flex gap-4">
23
+ <button
24
+ onClick={handleCreate}
25
+ className="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded text-sm transition-colors cursor-pointer"
26
+ >
27
+ Add Vendor (Modal)
28
+ </button>
29
+
30
+ <button
31
+ onClick={() =>
32
+ handleEdit({
33
+ id: "V-001",
34
+ name: "Acme Supplies",
35
+ email: "acme@mail.com",
36
+ phone: "555-0100",
37
+ status: "active",
38
+ })
39
+ }
40
+ className="px-4 py-2 border border-slate-200 rounded text-sm hover:bg-slate-50 transition-colors cursor-pointer"
41
+ >
42
+ Edit Acme (Modal)
43
+ </button>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,19 @@
1
+ import { useCallback } from "react";
2
+ import { useModalForm } from "../../hooks/useModalForm";
3
+ import { VendorModalForm, type VendorFormMode } from "./vendor-modal-form";
4
+ import type { Vendor } from "./vendor-types";
5
+
6
+ /**
7
+ * Domain hook for opening the Vendor form inside a centered MODAL.
8
+ * Uses useModalForm which automatically injects the `close` callback.
9
+ */
10
+ export function useVendorModalForm() {
11
+ const openForm = useModalForm();
12
+
13
+ return useCallback(
14
+ (mode: VendorFormMode = "create", vendor?: Vendor) => {
15
+ openForm(VendorModalForm, { mode, vendor });
16
+ },
17
+ [openForm],
18
+ );
19
+ }