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,134 @@
|
|
|
1
|
+
# Hotkeys Overlay & Event Manager
|
|
2
|
+
|
|
3
|
+
A general-purpose, dynamic keyboard shortcut manager for React. It automatically handles platform normalization (e.g. converting `ctrl` to `⌘` on macOS) and ignores triggers while typing in form fields by default. It also features a built-in help overlay (triggered by `Shift + ?`) that dynamically catalogs every hotkey currently active in your application tree.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation & Setup
|
|
8
|
+
|
|
9
|
+
1. Add the component via CLI:
|
|
10
|
+
```bash
|
|
11
|
+
npx pejay-ui add hotkeys
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
2. Wrap your application layout inside `HotkeyProvider`:
|
|
15
|
+
```tsx
|
|
16
|
+
import { HotkeyProvider } from "@/pejay-ui/hotkeys";
|
|
17
|
+
|
|
18
|
+
export default function App() {
|
|
19
|
+
return (
|
|
20
|
+
<HotkeyProvider>
|
|
21
|
+
<YourApp />
|
|
22
|
+
</HotkeyProvider>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Defining Your Own Key Combinations
|
|
30
|
+
|
|
31
|
+
You register shortcuts inside any functional component using the `useHotkey` hook. The registration automatically cleans up (unmounts) when the hosting component unmounts.
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { useHotkey } from "@/pejay-ui/hotkeys";
|
|
35
|
+
|
|
36
|
+
export function SalesDashboard() {
|
|
37
|
+
useHotkey("ctrl+s", (event) => {
|
|
38
|
+
// 1. The callback initiates your action
|
|
39
|
+
console.log("Triggered custom dashboard save!");
|
|
40
|
+
}, {
|
|
41
|
+
category: "Dashboard Actions", // Visual category grouping in the help overlay
|
|
42
|
+
description: "Save dashboard layout"
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return <div>Sales Dashboard</div>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Triggering Button Clicks via Hotkeys
|
|
52
|
+
|
|
53
|
+
When connecting a hotkey to a button, you have two options depending on your setup:
|
|
54
|
+
|
|
55
|
+
### Option A — Call the click handler directly (Recommended)
|
|
56
|
+
Trigger the function logic directly without worrying about the DOM:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { useHotkey } from "@/pejay-ui/hotkeys";
|
|
60
|
+
|
|
61
|
+
export function SaveDashboardPage() {
|
|
62
|
+
const handleSave = () => {
|
|
63
|
+
console.log("Saving dashboard data...");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Directly call the handler function in the callback
|
|
67
|
+
useHotkey("ctrl+s", () => handleSave(), {
|
|
68
|
+
category: "Dashboard",
|
|
69
|
+
description: "Save dashboard data"
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return <button onClick={handleSave}>Save Dashboard</button>;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Option B — Simulate a click on the DOM element (Using Refs)
|
|
77
|
+
If you want to simulate a physical mouse-click on the button (to trigger hover/active CSS styles or native click propagation), use a React `ref`:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { useRef } from "react";
|
|
81
|
+
import { useHotkey } from "@/pejay-ui/hotkeys";
|
|
82
|
+
|
|
83
|
+
export function DashboardPage() {
|
|
84
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
85
|
+
|
|
86
|
+
// Programmatically click the DOM element in the callback
|
|
87
|
+
useHotkey("ctrl+s", () => {
|
|
88
|
+
buttonRef.current?.click();
|
|
89
|
+
}, {
|
|
90
|
+
category: "Dashboard",
|
|
91
|
+
description: "Save dashboard data"
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return <button ref={buttonRef} onClick={() => console.log("DOM Clicked!")}>Save Dashboard</button>;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Hook API Reference
|
|
101
|
+
|
|
102
|
+
### `useHotkey(keys, callback, options)`
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
function useHotkey(
|
|
106
|
+
keys: string,
|
|
107
|
+
callback: (event: KeyboardEvent) => void,
|
|
108
|
+
options?: UseHotkeyOptions
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### The `options` Object
|
|
113
|
+
|
|
114
|
+
The 3rd parameter is an options object that configures how the hotkey behaves and how it displays inside the `Shift + ?` help overlay:
|
|
115
|
+
|
|
116
|
+
| Property | Type | Default | Description |
|
|
117
|
+
|:---|:---|:---|:---|
|
|
118
|
+
| **`description`** | `string` | *raw keys* | The human-readable action label shown next to keys in the help dialog. E.g. `"Submit and save current form"`. |
|
|
119
|
+
| **`category`** | `string` | `"General"` | Groups the shortcut under a specific visual header category in the help dialog. E.g. `"Form Actions"`. |
|
|
120
|
+
| **`enabledInInputs`** | `boolean` | `false` | If `true`, the hotkey triggers even if the user is typing inside text inputs, textareas, or select dropdowns. E.g. set to `true` for global overlays like `Escape`. |
|
|
121
|
+
| **`disabled`** | `boolean` | `false` | If `true`, the hotkey is temporarily deactivated. Useful for checking conditions like `disabled: !isFormDirty` or `disabled: isSubmitting`. |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Supported Modifier Keys & Formats
|
|
126
|
+
|
|
127
|
+
Specify your key combos in lowercase separated by `+`:
|
|
128
|
+
|
|
129
|
+
| Format | Output (macOS) | Output (Windows/Linux) |
|
|
130
|
+
|:---|:---|:---|
|
|
131
|
+
| `ctrl+enter` | `⌘ + Enter` | `Ctrl + Enter` |
|
|
132
|
+
| `alt+arrowleft` | `⌥ + ←` | `Alt + ←` |
|
|
133
|
+
| `shift+n` | `⇧ + N` | `Shift + N` |
|
|
134
|
+
| `escape` | `Esc` | `Esc` |
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { hotkeyRegistry } from "../core/registry";
|
|
3
|
+
import { isTypingTarget, matchHotkey } from "../core/key-matcher";
|
|
4
|
+
import { HotkeysHelpModal } from "./HotkeysHelpModal";
|
|
5
|
+
|
|
6
|
+
interface HotkeyContextProps {
|
|
7
|
+
showHelp: () => void;
|
|
8
|
+
hideHelp: () => void;
|
|
9
|
+
isHelpOpen: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HotkeyContext = createContext<HotkeyContextProps | undefined>(undefined);
|
|
13
|
+
|
|
14
|
+
export function useHotkeyContext() {
|
|
15
|
+
const context = useContext(HotkeyContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("useHotkeyContext must be used within HotkeyProvider");
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface HotkeyProviderProps {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
/** Custom overlay triggers, e.g. custom key combination to open helper. Default is 'shift+?' */
|
|
25
|
+
helpTriggerKey?: string;
|
|
26
|
+
/** Disable the built-in help dialog overlay. Default: false. */
|
|
27
|
+
disableHelpOverlay?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function HotkeyProvider({
|
|
31
|
+
children,
|
|
32
|
+
helpTriggerKey = "shift+?",
|
|
33
|
+
disableHelpOverlay = false,
|
|
34
|
+
}: HotkeyProviderProps) {
|
|
35
|
+
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
|
36
|
+
|
|
37
|
+
const showHelp = () => setIsHelpOpen(true);
|
|
38
|
+
const hideHelp = () => setIsHelpOpen(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
42
|
+
const isTyping = isTypingTarget(event.target);
|
|
43
|
+
|
|
44
|
+
// Check help trigger first
|
|
45
|
+
if (!disableHelpOverlay && matchHotkey(event, helpTriggerKey)) {
|
|
46
|
+
if (isTyping) return;
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
setIsHelpOpen((prev) => !prev);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Process active hotkeys in reverse order (newest registered / topmost takes priority)
|
|
53
|
+
const activeHotkeys = [...hotkeyRegistry.getActiveHotkeys()].reverse();
|
|
54
|
+
for (const config of activeHotkeys) {
|
|
55
|
+
if (matchHotkey(event, config.keys)) {
|
|
56
|
+
if (isTyping && !config.enabledInInputs) {
|
|
57
|
+
continue; // Skip because focused in input
|
|
58
|
+
}
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
config.callback(event);
|
|
61
|
+
break; // Stop at first match
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
67
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
68
|
+
}, [helpTriggerKey, disableHelpOverlay]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<HotkeyContext.Provider value={{ showHelp, hideHelp, isHelpOpen }}>
|
|
72
|
+
{children}
|
|
73
|
+
{!disableHelpOverlay && isHelpOpen && (
|
|
74
|
+
<HotkeysHelpModal close={hideHelp} />
|
|
75
|
+
)}
|
|
76
|
+
</HotkeyContext.Provider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { hotkeyRegistry } from "../core/registry";
|
|
3
|
+
import { formatKeyCombo } from "../core/key-matcher";
|
|
4
|
+
import type { HotkeyConfig } from "../core/types";
|
|
5
|
+
|
|
6
|
+
interface HotkeysHelpModalProps {
|
|
7
|
+
close: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function HotkeysHelpModal({ close }: HotkeysHelpModalProps) {
|
|
11
|
+
const [shortcuts, setShortcuts] = useState<HotkeyConfig[]>([]);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
// Esc key to close this modal directly
|
|
15
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
16
|
+
if (e.key === "Escape") {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
close();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
22
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
23
|
+
}, [close]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
return hotkeyRegistry.subscribe((list) => {
|
|
27
|
+
setShortcuts(list);
|
|
28
|
+
});
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
// Group hotkeys by category
|
|
32
|
+
const grouped = shortcuts.reduce<Record<string, HotkeyConfig[]>>((acc, item) => {
|
|
33
|
+
const cat = item.category || "General";
|
|
34
|
+
if (!acc[cat]) acc[cat] = [];
|
|
35
|
+
acc[cat].push(item);
|
|
36
|
+
return acc;
|
|
37
|
+
}, {});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className="fixed inset-0 z-[9990] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm"
|
|
42
|
+
onClick={close}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
className="w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[85vh]"
|
|
46
|
+
onClick={(e) => e.stopPropagation()}
|
|
47
|
+
>
|
|
48
|
+
{/* Header */}
|
|
49
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/50">
|
|
50
|
+
<div>
|
|
51
|
+
<h2 className="text-lg font-bold text-slate-800">Keyboard Shortcuts</h2>
|
|
52
|
+
<p className="text-xs text-slate-500 mt-0.5">Quick access shortcuts available on this page</p>
|
|
53
|
+
</div>
|
|
54
|
+
<button
|
|
55
|
+
onClick={close}
|
|
56
|
+
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
|
|
57
|
+
>
|
|
58
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
59
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Shortcuts List */}
|
|
65
|
+
<div className="p-6 overflow-y-auto space-y-6 flex-1 min-h-0">
|
|
66
|
+
{Object.keys(grouped).length === 0 ? (
|
|
67
|
+
<p className="text-center text-sm text-slate-400">No active shortcuts registered.</p>
|
|
68
|
+
) : (
|
|
69
|
+
Object.entries(grouped).map(([category, items]) => (
|
|
70
|
+
<div key={category} className="space-y-3">
|
|
71
|
+
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{category}</h3>
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
{items.map((item, idx) => (
|
|
74
|
+
<div
|
|
75
|
+
key={idx}
|
|
76
|
+
className="flex items-center justify-between text-sm py-1.5 border-b border-slate-50 last:border-0"
|
|
77
|
+
>
|
|
78
|
+
<span className="text-slate-600 font-medium">{item.description}</span>
|
|
79
|
+
<kbd className="px-2.5 py-1 bg-slate-100 border border-slate-200 rounded-lg shadow-sm text-xs font-semibold text-slate-700 font-mono tracking-wide">
|
|
80
|
+
{formatKeyCombo(item.keys)}
|
|
81
|
+
</kbd>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
))
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Footer */}
|
|
91
|
+
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex justify-end">
|
|
92
|
+
<button
|
|
93
|
+
onClick={close}
|
|
94
|
+
className="px-4 py-2 bg-slate-800 hover:bg-slate-900 text-white text-sm font-semibold rounded-xl transition-all shadow-sm"
|
|
95
|
+
>
|
|
96
|
+
Close Dialog
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 formatKeyCombo(keys: string): string {
|
|
7
|
+
const parts = keys.toLowerCase().split("+");
|
|
8
|
+
const isMac = isMacPlatform();
|
|
9
|
+
|
|
10
|
+
return parts
|
|
11
|
+
.map((part) => {
|
|
12
|
+
switch (part) {
|
|
13
|
+
case "ctrl":
|
|
14
|
+
return isMac ? "⌘" : "Ctrl";
|
|
15
|
+
case "alt":
|
|
16
|
+
return isMac ? "⌥" : "Alt";
|
|
17
|
+
case "shift":
|
|
18
|
+
return isMac ? "⇧" : "Shift";
|
|
19
|
+
case "meta":
|
|
20
|
+
return isMac ? "⌘" : "Win";
|
|
21
|
+
case "enter":
|
|
22
|
+
return "Enter";
|
|
23
|
+
case "escape":
|
|
24
|
+
return "Esc";
|
|
25
|
+
case "arrowleft":
|
|
26
|
+
return "←";
|
|
27
|
+
case "arrowright":
|
|
28
|
+
return "→";
|
|
29
|
+
case "arrowup":
|
|
30
|
+
return "↑";
|
|
31
|
+
case "arrowdown":
|
|
32
|
+
return "↓";
|
|
33
|
+
default:
|
|
34
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.join(" + ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isTypingTarget(target: EventTarget | null) {
|
|
41
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
42
|
+
if (target.isContentEditable) return true;
|
|
43
|
+
|
|
44
|
+
const tag = target.tagName;
|
|
45
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
46
|
+
|
|
47
|
+
return Boolean(target.closest("[data-overlay-popover]"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function matchHotkey(event: KeyboardEvent, combo: string): boolean {
|
|
51
|
+
const parts = combo.toLowerCase().split("+");
|
|
52
|
+
const isMac = isMacPlatform();
|
|
53
|
+
|
|
54
|
+
let requiredCtrl = false;
|
|
55
|
+
let requiredAlt = false;
|
|
56
|
+
let requiredShift = false;
|
|
57
|
+
let requiredMeta = false;
|
|
58
|
+
let targetKey: string | null = null;
|
|
59
|
+
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
if (part === "ctrl") {
|
|
62
|
+
if (isMac) {
|
|
63
|
+
requiredMeta = true; // Use Meta (⌘) instead of Control on Mac by default
|
|
64
|
+
} else {
|
|
65
|
+
requiredCtrl = true;
|
|
66
|
+
}
|
|
67
|
+
} else if (part === "mod") {
|
|
68
|
+
if (isMac) {
|
|
69
|
+
requiredMeta = true;
|
|
70
|
+
} else {
|
|
71
|
+
requiredCtrl = true;
|
|
72
|
+
}
|
|
73
|
+
} else if (part === "alt") {
|
|
74
|
+
requiredAlt = true;
|
|
75
|
+
} else if (part === "shift") {
|
|
76
|
+
requiredShift = true;
|
|
77
|
+
} else if (part === "meta") {
|
|
78
|
+
requiredMeta = true;
|
|
79
|
+
} else {
|
|
80
|
+
targetKey = part;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check modifiers
|
|
85
|
+
const ctrlMatch = event.ctrlKey === requiredCtrl;
|
|
86
|
+
const altMatch = event.altKey === requiredAlt;
|
|
87
|
+
const shiftMatch = event.shiftKey === requiredShift;
|
|
88
|
+
const metaMatch = event.metaKey === requiredMeta;
|
|
89
|
+
|
|
90
|
+
if (!ctrlMatch || !altMatch || !shiftMatch || !metaMatch) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!targetKey) return false;
|
|
95
|
+
|
|
96
|
+
const eventKey = event.key.toLowerCase();
|
|
97
|
+
|
|
98
|
+
// Normalize simple keys
|
|
99
|
+
if (targetKey === "esc") targetKey = "escape";
|
|
100
|
+
if (targetKey === "up") targetKey = "arrowup";
|
|
101
|
+
if (targetKey === "down") targetKey = "arrowdown";
|
|
102
|
+
if (targetKey === "left") targetKey = "arrowleft";
|
|
103
|
+
if (targetKey === "right") targetKey = "arrowright";
|
|
104
|
+
|
|
105
|
+
return eventKey === targetKey;
|
|
106
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HotkeyConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
type Listener = (hotkeys: HotkeyConfig[]) => void;
|
|
4
|
+
|
|
5
|
+
class HotkeyRegistry {
|
|
6
|
+
private hotkeys: Map<string, HotkeyConfig> = new Map();
|
|
7
|
+
private listeners: Set<Listener> = new Set();
|
|
8
|
+
|
|
9
|
+
register(id: string, config: HotkeyConfig) {
|
|
10
|
+
this.hotkeys.set(id, config);
|
|
11
|
+
this.notify();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
unregister(id: string) {
|
|
15
|
+
if (this.hotkeys.delete(id)) {
|
|
16
|
+
this.notify();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getActiveHotkeys(): HotkeyConfig[] {
|
|
21
|
+
return Array.from(this.hotkeys.values());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
subscribe(listener: Listener) {
|
|
25
|
+
this.listeners.add(listener);
|
|
26
|
+
// Initial call
|
|
27
|
+
listener(this.getActiveHotkeys());
|
|
28
|
+
return () => {
|
|
29
|
+
this.listeners.delete(listener);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private notify() {
|
|
34
|
+
const list = this.getActiveHotkeys();
|
|
35
|
+
this.listeners.forEach((listener) => listener(list));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const hotkeyRegistry = new HotkeyRegistry();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface HotkeyConfig {
|
|
2
|
+
/**
|
|
3
|
+
* The key combination string, e.g., 'ctrl+enter', 'escape', 'alt+arrowleft', 'shift+?'.
|
|
4
|
+
* Separate keys with '+'. Case-insensitive.
|
|
5
|
+
*/
|
|
6
|
+
keys: string;
|
|
7
|
+
/** Description of what this shortcut does. */
|
|
8
|
+
description: string;
|
|
9
|
+
/** Section/Category group to display this hotkey under in the help dialog. */
|
|
10
|
+
category?: string;
|
|
11
|
+
/** The action to execute when key combination matches. */
|
|
12
|
+
callback: (event: KeyboardEvent) => void;
|
|
13
|
+
/** If true, the hotkey will still trigger even if focusing an input, textarea, or contentEditable element. Default: false. */
|
|
14
|
+
enabledInInputs?: boolean;
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { hotkeyRegistry } from "../core/registry";
|
|
3
|
+
import type { HotkeyConfig } from "../core/types";
|
|
4
|
+
|
|
5
|
+
interface UseHotkeyOptions {
|
|
6
|
+
category?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
enabledInInputs?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useHotkey(
|
|
13
|
+
keys: string,
|
|
14
|
+
callback: (event: KeyboardEvent) => void,
|
|
15
|
+
options: UseHotkeyOptions = {},
|
|
16
|
+
) {
|
|
17
|
+
const callbackRef = useRef(callback);
|
|
18
|
+
callbackRef.current = callback;
|
|
19
|
+
|
|
20
|
+
const disabled = Boolean(options.disabled);
|
|
21
|
+
const category = options.category;
|
|
22
|
+
const description = options.description;
|
|
23
|
+
const enabledInInputs = options.enabledInInputs;
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (disabled) return;
|
|
27
|
+
|
|
28
|
+
const id = crypto.randomUUID();
|
|
29
|
+
const config: HotkeyConfig = {
|
|
30
|
+
keys,
|
|
31
|
+
description: description || keys,
|
|
32
|
+
category,
|
|
33
|
+
enabledInInputs,
|
|
34
|
+
callback: (e) => callbackRef.current(e),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
hotkeyRegistry.register(id, config);
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
hotkeyRegistry.unregister(id);
|
|
41
|
+
};
|
|
42
|
+
}, [keys, disabled, category, description, enabledInInputs]);
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useLayoutEffect, useEffect } from "react";
|
|
2
2
|
import { Menu, PanelLeftOpen, PanelRightOpen, X } from "lucide-react";
|
|
3
|
-
import { cn } from "@/utils/cn";
|
|
3
|
+
import { cn } from "@/pejay-ui/utils/cn";
|
|
4
4
|
import { MenuSection, SidebarMenu } from "./sidebar-menu";
|
|
5
5
|
|
|
6
6
|
|