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,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Flex } from "../layout";
|
|
3
|
+
import { cn } from "@/utils";
|
|
4
|
+
|
|
5
|
+
interface BaseCardProps {
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
/** Override padding. Defaults to "p-2.5" */
|
|
8
|
+
padding?: string;
|
|
9
|
+
/** Override border radius. Defaults to "rounded-md" */
|
|
10
|
+
rounded?: string;
|
|
11
|
+
/** CSS height value e.g. "200px" | "50vh". min-h-0 is always applied. */
|
|
12
|
+
height?: string;
|
|
13
|
+
/** Override width. Defaults to "w-full" */
|
|
14
|
+
width?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function BaseCard({
|
|
19
|
+
children,
|
|
20
|
+
padding = "p-2.5",
|
|
21
|
+
rounded = "rounded-md",
|
|
22
|
+
height,
|
|
23
|
+
width = "w-full",
|
|
24
|
+
className,
|
|
25
|
+
}: BaseCardProps) {
|
|
26
|
+
return (
|
|
27
|
+
<Flex
|
|
28
|
+
direction="row"
|
|
29
|
+
className={cn("min-h-0", rounded, width, padding, className)}
|
|
30
|
+
style={height ? { height } : undefined}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</Flex>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "@/utils";
|
|
4
|
+
|
|
5
|
+
interface ModalCardProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
footer?: ReactNode;
|
|
11
|
+
close?: () => void;
|
|
12
|
+
width?: string;
|
|
13
|
+
maxHeight?: string;
|
|
14
|
+
/** Renders below title row (e.g. tabs). */
|
|
15
|
+
headerSlot?: ReactNode;
|
|
16
|
+
bodyClassName?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ModalCard({
|
|
20
|
+
children,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
footer,
|
|
24
|
+
className,
|
|
25
|
+
close,
|
|
26
|
+
width = "min(560px, 92vw)",
|
|
27
|
+
maxHeight = "min(90vh, 720px)",
|
|
28
|
+
headerSlot,
|
|
29
|
+
bodyClassName,
|
|
30
|
+
}: ModalCardProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"flex flex-col overflow-hidden rounded-xl bg-white shadow-2xl",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
style={{ width, maxHeight }}
|
|
38
|
+
onClick={(e) => e.stopPropagation()}
|
|
39
|
+
role="dialog"
|
|
40
|
+
aria-modal="true"
|
|
41
|
+
aria-labelledby={title ? "modal-title" : undefined}
|
|
42
|
+
>
|
|
43
|
+
<div className="shrink-0 border-b border-slate-200 px-6 pt-5 pb-0">
|
|
44
|
+
<div className="flex items-start justify-between gap-3 pb-4">
|
|
45
|
+
<div className="min-w-0">
|
|
46
|
+
{title && (
|
|
47
|
+
<h2
|
|
48
|
+
id="modal-title"
|
|
49
|
+
className="text-lg font-bold tracking-tight text-slate-900"
|
|
50
|
+
>
|
|
51
|
+
{title}
|
|
52
|
+
</h2>
|
|
53
|
+
)}
|
|
54
|
+
{description && (
|
|
55
|
+
<p className="mt-0.5 text-sm text-slate-500">{description}</p>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
{close && (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={close}
|
|
62
|
+
className="shrink-0 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
|
63
|
+
aria-label="Close"
|
|
64
|
+
>
|
|
65
|
+
<X className="h-4 w-4" strokeWidth={2} />
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
{headerSlot}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"min-h-0 flex-1 overflow-y-auto px-6 py-5",
|
|
75
|
+
bodyClassName,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{footer && (
|
|
82
|
+
<div className="shrink-0 border-t border-slate-200 bg-white px-6 py-4">
|
|
83
|
+
{footer}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Flex } from "../layout";
|
|
4
|
+
import { cn } from "@/utils";
|
|
5
|
+
import type { FormTabsConfig } from "@/hooks/form-overlay-registry";
|
|
6
|
+
import { requestOverlayCloseWithConfirm } from "@/hooks/overlay-close";
|
|
7
|
+
import { useFormOverlayRegistration } from "@/hooks/useFormOverlayRegistration";
|
|
8
|
+
|
|
9
|
+
export type SidePanelSize = "sm" | "md" | "lg" | "xl";
|
|
10
|
+
|
|
11
|
+
const PANEL_WIDTH: Record<SidePanelSize, string> = {
|
|
12
|
+
sm: "min(480px, 100vw)",
|
|
13
|
+
md: "min(560px, 100vw)",
|
|
14
|
+
lg: "min(720px, 92vw)",
|
|
15
|
+
xl: "min(840px, 95vw)",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface SidePanelCardProps {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
close?: () => void;
|
|
24
|
+
footer?: ReactNode;
|
|
25
|
+
/** Preset widths — use `width` to override entirely. */
|
|
26
|
+
size?: SidePanelSize;
|
|
27
|
+
/** Custom CSS width; overrides `size`. */
|
|
28
|
+
width?: string;
|
|
29
|
+
/** Renders below title row (e.g. tabs). */
|
|
30
|
+
headerSlot?: ReactNode;
|
|
31
|
+
bodyClassName?: string;
|
|
32
|
+
onSubmit?: () => void;
|
|
33
|
+
isDirty?: boolean;
|
|
34
|
+
/** Blocks close via X, Escape, and backdrop while an async action is in progress. */
|
|
35
|
+
closeDisabled?: boolean;
|
|
36
|
+
formTabs?: FormTabsConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SidePanelCard({
|
|
40
|
+
children,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
className,
|
|
44
|
+
close,
|
|
45
|
+
footer,
|
|
46
|
+
size = "sm",
|
|
47
|
+
width,
|
|
48
|
+
headerSlot,
|
|
49
|
+
bodyClassName,
|
|
50
|
+
onSubmit,
|
|
51
|
+
isDirty = false,
|
|
52
|
+
closeDisabled = false,
|
|
53
|
+
formTabs,
|
|
54
|
+
}: SidePanelCardProps) {
|
|
55
|
+
const panelWidth = width ?? PANEL_WIDTH[size];
|
|
56
|
+
|
|
57
|
+
useFormOverlayRegistration({
|
|
58
|
+
enabled: Boolean(close),
|
|
59
|
+
onSubmit,
|
|
60
|
+
isDirty,
|
|
61
|
+
closeDisabled,
|
|
62
|
+
formTabs,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const handleClose = () => {
|
|
66
|
+
if (closeDisabled) return;
|
|
67
|
+
requestOverlayCloseWithConfirm();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Flex
|
|
72
|
+
direction="column"
|
|
73
|
+
items="stretch"
|
|
74
|
+
noGap
|
|
75
|
+
className={cn(
|
|
76
|
+
"h-screen max-w-full overflow-hidden border-l border-slate-200 bg-white shadow-2xl",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
style={{ width: panelWidth }}
|
|
80
|
+
>
|
|
81
|
+
<div className="w-full shrink-0 border-b border-slate-200 px-6 pt-4 pb-0">
|
|
82
|
+
<Flex direction="row" items="center" justify="between" className="pb-4">
|
|
83
|
+
<Flex direction="column" className="min-w-0 gap-0.5">
|
|
84
|
+
{title && (
|
|
85
|
+
<h2 className="text-lg font-bold tracking-tight text-slate-900">
|
|
86
|
+
{title}
|
|
87
|
+
</h2>
|
|
88
|
+
)}
|
|
89
|
+
{description && (
|
|
90
|
+
<p className="text-sm text-slate-500">{description}</p>
|
|
91
|
+
)}
|
|
92
|
+
</Flex>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={handleClose}
|
|
96
|
+
disabled={closeDisabled}
|
|
97
|
+
className={cn(
|
|
98
|
+
"shrink-0 rounded-full p-2 text-slate-600 transition-colors",
|
|
99
|
+
closeDisabled
|
|
100
|
+
? "cursor-not-allowed opacity-40"
|
|
101
|
+
: "cursor-pointer hover:bg-slate-100 hover:text-slate-600",
|
|
102
|
+
)}
|
|
103
|
+
aria-label="Close panel"
|
|
104
|
+
>
|
|
105
|
+
<X className="h-4 w-4" strokeWidth={2} />
|
|
106
|
+
</button>
|
|
107
|
+
</Flex>
|
|
108
|
+
{headerSlot}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div
|
|
112
|
+
className={cn(
|
|
113
|
+
"min-h-0 w-full flex-1 overflow-y-auto px-6 py-5",
|
|
114
|
+
bodyClassName,
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{footer && (
|
|
121
|
+
<div className="w-full shrink-0 border-t border-slate-200 bg-white px-6 py-4">
|
|
122
|
+
{footer}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</Flex>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type FormTabsConfig = {
|
|
2
|
+
active: string;
|
|
3
|
+
setActive: (id: string) => void;
|
|
4
|
+
order: string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FormOverlayRegistration = {
|
|
8
|
+
onSubmit?: () => void;
|
|
9
|
+
isDirty?: () => boolean;
|
|
10
|
+
isCloseBlocked?: () => boolean;
|
|
11
|
+
tabs?: FormTabsConfig;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let activeRequestClose: (() => void) | null = null;
|
|
15
|
+
let activeFormRegistration: FormOverlayRegistration | null = null;
|
|
16
|
+
let unsavedConfirmOpen = false;
|
|
17
|
+
|
|
18
|
+
export function setActiveOverlayClose(handler: (() => void) | null) {
|
|
19
|
+
activeRequestClose = handler;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerActiveFormOverlay(
|
|
23
|
+
registration: FormOverlayRegistration | null,
|
|
24
|
+
) {
|
|
25
|
+
activeFormRegistration = registration;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setUnsavedConfirmOpen(open: boolean) {
|
|
29
|
+
unsavedConfirmOpen = open;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isUnsavedConfirmOpen() {
|
|
33
|
+
return unsavedConfirmOpen;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getActiveOverlayClose() {
|
|
37
|
+
return activeRequestClose;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getActiveFormOverlay() {
|
|
41
|
+
return activeFormRegistration;
|
|
42
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Btn, ModalCard } from "@/components/base";
|
|
3
|
+
import { formatModKey } from "@/hooks/keyboard-utils";
|
|
4
|
+
|
|
5
|
+
const MOD = formatModKey();
|
|
6
|
+
|
|
7
|
+
const SHORTCUT_GROUPS = [
|
|
8
|
+
{
|
|
9
|
+
title: "Form (side panel / modal)",
|
|
10
|
+
items: [
|
|
11
|
+
{ keys: `${MOD} + Enter`, action: "Submit form" },
|
|
12
|
+
{ keys: "Alt + ← / →", action: "Previous / next form tab" },
|
|
13
|
+
{ keys: "Esc", action: "Close form (confirm if unsaved)" },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
title: "List pages",
|
|
18
|
+
items: [
|
|
19
|
+
{ keys: `${MOD} + Alt + N`, action: "New record / open add form" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
title: "Global",
|
|
24
|
+
items: [
|
|
25
|
+
{ keys: "Shift + ?", action: "Show this shortcut guide" },
|
|
26
|
+
{ keys: `${MOD} + K`, action: "Focus search" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
type KeyboardShortcutsHelpProps = {
|
|
32
|
+
close: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function KeyboardShortcutsHelp({ close }: KeyboardShortcutsHelpProps) {
|
|
36
|
+
return (
|
|
37
|
+
<ModalCard
|
|
38
|
+
close={close}
|
|
39
|
+
title="Keyboard shortcuts"
|
|
40
|
+
description="Shortcuts for forms, lists, and navigation."
|
|
41
|
+
width="min(36rem, 92vw)"
|
|
42
|
+
footer={
|
|
43
|
+
<div className="flex justify-end">
|
|
44
|
+
<Btn variant="primary" rounded="lg" onClick={close}>
|
|
45
|
+
Close
|
|
46
|
+
</Btn>
|
|
47
|
+
</div>
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
<div className="space-y-6">
|
|
51
|
+
{SHORTCUT_GROUPS.map((group) => (
|
|
52
|
+
<section key={group.title}>
|
|
53
|
+
<h3 className="mb-3 text-sm font-semibold text-slate-900">
|
|
54
|
+
{group.title}
|
|
55
|
+
</h3>
|
|
56
|
+
<ul className="space-y-2">
|
|
57
|
+
{group.items.map((item) => (
|
|
58
|
+
<li
|
|
59
|
+
key={item.keys}
|
|
60
|
+
className="flex items-center justify-between gap-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
|
61
|
+
>
|
|
62
|
+
<span className="text-sm text-slate-600">{item.action}</span>
|
|
63
|
+
<kbd className="shrink-0 rounded-md border border-slate-200 bg-white px-2 py-1 font-mono text-xs text-slate-800">
|
|
64
|
+
{item.keys}
|
|
65
|
+
</kbd>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
</section>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</ModalCard>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useKeyboardShortcutsHelp() {
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
return { helpOpen: open, setHelpOpen: setOpen };
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function isMacPlatform() {
|
|
2
|
+
if (typeof navigator === "undefined") return false;
|
|
3
|
+
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isModKey(event: KeyboardEvent) {
|
|
7
|
+
return isMacPlatform() ? event.metaKey : event.ctrlKey;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isTypingTarget(target: EventTarget | null) {
|
|
11
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
12
|
+
if (target.isContentEditable) return true;
|
|
13
|
+
|
|
14
|
+
const tag = target.tagName;
|
|
15
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
16
|
+
|
|
17
|
+
return Boolean(target.closest("[data-overlay-popover]"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatModKey() {
|
|
21
|
+
return isMacPlatform() ? "⌘" : "Ctrl";
|
|
22
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { Flex } from "../layout/flex";
|
|
3
|
+
import type { FlexProps } from "@/types";
|
|
4
|
+
import { getOverlayBackdropZ } from "@/utils/constants/z-index";
|
|
5
|
+
import { requestOverlayCloseWithConfirm } from "@/hooks/overlay-close";
|
|
6
|
+
|
|
7
|
+
interface BackdropProps {
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
justify?: FlexProps["justify"];
|
|
11
|
+
items?: FlexProps["items"];
|
|
12
|
+
direction?: FlexProps["direction"];
|
|
13
|
+
visible?: boolean;
|
|
14
|
+
isActive?: boolean;
|
|
15
|
+
layer?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Backdrop({
|
|
19
|
+
children,
|
|
20
|
+
onClose,
|
|
21
|
+
justify,
|
|
22
|
+
items,
|
|
23
|
+
direction,
|
|
24
|
+
visible = true,
|
|
25
|
+
isActive = true,
|
|
26
|
+
layer = 1,
|
|
27
|
+
}: BackdropProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!isActive) return;
|
|
30
|
+
|
|
31
|
+
const originalOverflow = document.body.style.overflow;
|
|
32
|
+
document.body.style.overflow = "hidden";
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
document.body.style.overflow = originalOverflow;
|
|
36
|
+
};
|
|
37
|
+
}, [isActive]);
|
|
38
|
+
|
|
39
|
+
if (!isActive) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="pointer-events-none invisible fixed inset-0" aria-hidden>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<style>{`
|
|
50
|
+
@keyframes backdropFadeIn {
|
|
51
|
+
from {
|
|
52
|
+
opacity: 0;
|
|
53
|
+
backdrop-filter: blur(0px);
|
|
54
|
+
}
|
|
55
|
+
to {
|
|
56
|
+
opacity: 1;
|
|
57
|
+
backdrop-filter: blur(4px);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
@keyframes backdropFadeOut {
|
|
61
|
+
from {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
backdrop-filter: blur(4px);
|
|
64
|
+
}
|
|
65
|
+
to {
|
|
66
|
+
opacity: 0;
|
|
67
|
+
backdrop-filter: blur(0px);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
.animate-backdrop-fade {
|
|
71
|
+
animation: backdropFadeIn 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
72
|
+
}
|
|
73
|
+
.animate-backdrop-fade-out {
|
|
74
|
+
animation: backdropFadeOut 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
75
|
+
}
|
|
76
|
+
`}</style>
|
|
77
|
+
|
|
78
|
+
<Flex
|
|
79
|
+
direction={direction || "row"}
|
|
80
|
+
items={items || "center"}
|
|
81
|
+
justify={justify || "center"}
|
|
82
|
+
className="inset-0 fixed overflow-hidden"
|
|
83
|
+
style={{ zIndex: getOverlayBackdropZ(layer) }}
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
onClick={() => requestOverlayCloseWithConfirm()}
|
|
87
|
+
className={`absolute inset-0 cursor-pointer bg-black/50 ${
|
|
88
|
+
visible ? "animate-backdrop-fade" : "animate-backdrop-fade-out"
|
|
89
|
+
}`}
|
|
90
|
+
/>
|
|
91
|
+
<div className="relative z-10">{children}</div>
|
|
92
|
+
</Flex>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { type Provider as Props } from "@/types";
|
|
3
|
+
import { setActiveOverlayClose } from "@/hooks/form-overlay-registry";
|
|
4
|
+
import { Portal } from "./portal";
|
|
5
|
+
import { Backdrop } from "./backdrop";
|
|
6
|
+
import { getOverlayContentZ } from "@/utils/constants/z-index";
|
|
7
|
+
|
|
8
|
+
interface ModalBaseProps {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
options: Props.OverlayOptions;
|
|
12
|
+
isActive?: boolean;
|
|
13
|
+
layer?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Modal = ({
|
|
17
|
+
children,
|
|
18
|
+
onClose,
|
|
19
|
+
options,
|
|
20
|
+
isActive = true,
|
|
21
|
+
layer = 1,
|
|
22
|
+
}: ModalBaseProps) => {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!isActive || !onClose) return;
|
|
25
|
+
setActiveOverlayClose(onClose);
|
|
26
|
+
return () => setActiveOverlayClose(null);
|
|
27
|
+
}, [isActive, onClose]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Portal>
|
|
31
|
+
<Backdrop
|
|
32
|
+
{...options}
|
|
33
|
+
onClose={onClose}
|
|
34
|
+
isActive={isActive}
|
|
35
|
+
layer={layer}
|
|
36
|
+
>
|
|
37
|
+
<div className="relative" style={{ zIndex: getOverlayContentZ(layer) }}>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
</Backdrop>
|
|
41
|
+
</Portal>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { type Provider as Props } from "@/types";
|
|
11
|
+
import { Portal } from "./portal";
|
|
12
|
+
import { getOverlayBackdropZ, getOverlayContentZ } from "@/utils/constants/z-index";
|
|
13
|
+
import { setActiveOverlayClose } from "@/hooks/form-overlay-registry";
|
|
14
|
+
import { requestOverlayCloseWithConfirm } from "@/hooks/overlay-close";
|
|
15
|
+
|
|
16
|
+
const panelSpring = { type: "spring" as const, damping: 25, stiffness: 200 };
|
|
17
|
+
|
|
18
|
+
const SidePanelCloseContext = createContext<(() => void) | null>(null);
|
|
19
|
+
|
|
20
|
+
/** Animated close — use this instead of AppProvider's immediate close. */
|
|
21
|
+
export function useAnimatedSidePanelClose() {
|
|
22
|
+
const close = useContext(SidePanelCloseContext);
|
|
23
|
+
if (!close) {
|
|
24
|
+
throw new Error("useAnimatedSidePanelClose must be used within SidePanel");
|
|
25
|
+
}
|
|
26
|
+
return close;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SidePanelBaseProps {
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
onClose?: () => void;
|
|
32
|
+
options: Props.OverlayOptions;
|
|
33
|
+
isActive?: boolean;
|
|
34
|
+
layer?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SidePanel({
|
|
38
|
+
children,
|
|
39
|
+
onClose,
|
|
40
|
+
options,
|
|
41
|
+
isActive = true,
|
|
42
|
+
layer = 1,
|
|
43
|
+
}: SidePanelBaseProps) {
|
|
44
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
45
|
+
const isLeft = options?.onSide === "left";
|
|
46
|
+
|
|
47
|
+
const requestClose = useCallback(() => setIsOpen(false), []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!isActive) return;
|
|
51
|
+
setActiveOverlayClose(requestClose);
|
|
52
|
+
return () => setActiveOverlayClose(null);
|
|
53
|
+
}, [isActive, requestClose]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isOpen || !isActive) return;
|
|
57
|
+
|
|
58
|
+
const originalOverflow = document.body.style.overflow;
|
|
59
|
+
document.body.style.overflow = "hidden";
|
|
60
|
+
return () => {
|
|
61
|
+
document.body.style.overflow = originalOverflow;
|
|
62
|
+
};
|
|
63
|
+
}, [isActive, isOpen]);
|
|
64
|
+
|
|
65
|
+
const offscreenX = isLeft ? "-100%" : "100%";
|
|
66
|
+
const panelPosition = isLeft ? "left-0" : "right-0";
|
|
67
|
+
const backdropZ = getOverlayBackdropZ(layer);
|
|
68
|
+
const panelZ = getOverlayContentZ(layer);
|
|
69
|
+
|
|
70
|
+
if (!isActive) {
|
|
71
|
+
return (
|
|
72
|
+
<Portal>
|
|
73
|
+
<div className="pointer-events-none invisible fixed inset-0" aria-hidden>
|
|
74
|
+
<SidePanelCloseContext.Provider value={requestClose}>
|
|
75
|
+
{children}
|
|
76
|
+
</SidePanelCloseContext.Provider>
|
|
77
|
+
</div>
|
|
78
|
+
</Portal>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Portal>
|
|
84
|
+
<SidePanelCloseContext.Provider value={requestClose}>
|
|
85
|
+
<AnimatePresence onExitComplete={() => onClose?.()}>
|
|
86
|
+
{isOpen && (
|
|
87
|
+
<>
|
|
88
|
+
<motion.div
|
|
89
|
+
key="side-panel-backdrop"
|
|
90
|
+
initial={{ opacity: 0 }}
|
|
91
|
+
animate={{ opacity: 1 }}
|
|
92
|
+
exit={{ opacity: 0 }}
|
|
93
|
+
transition={{ duration: 0.2 }}
|
|
94
|
+
className="fixed inset-0 bg-slate-900/20 backdrop-blur-sm"
|
|
95
|
+
style={{ zIndex: backdropZ }}
|
|
96
|
+
onClick={requestOverlayCloseWithConfirm}
|
|
97
|
+
aria-hidden
|
|
98
|
+
/>
|
|
99
|
+
<motion.div
|
|
100
|
+
key="side-panel-panel"
|
|
101
|
+
initial={{ x: offscreenX }}
|
|
102
|
+
animate={{ x: 0 }}
|
|
103
|
+
exit={{ x: offscreenX }}
|
|
104
|
+
transition={panelSpring}
|
|
105
|
+
className={`fixed inset-y-0 flex h-full ${panelPosition}`}
|
|
106
|
+
style={{ zIndex: panelZ }}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</motion.div>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</AnimatePresence>
|
|
113
|
+
</SidePanelCloseContext.Provider>
|
|
114
|
+
</Portal>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type SidePanelContentProps = {
|
|
119
|
+
content?: Props.OverlayContent;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Renders provider content with animated `close` wired to the panel. */
|
|
123
|
+
export function SidePanelContent({ content }: SidePanelContentProps) {
|
|
124
|
+
const requestClose = useAnimatedSidePanelClose();
|
|
125
|
+
return content?.({ close: requestClose }) ?? null;
|
|
126
|
+
}
|