pejay-ui 1.4.2 → 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 +77 -54
  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
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from "react";
2
2
  import { Input } from "./input";
3
3
  import { Check, Eye, EyeOff, AlertTriangle } from "lucide-react";
4
- import { cn } from "@/utils/cn";
4
+ import { cn } from "@/pejay-ui/utils/cn";
5
5
 
6
6
  /*
7
7
  * ============================================================================
@@ -0,0 +1,24 @@
1
+ # EmailInput Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { EmailInput } from "@/pejay-ui/components/form";
11
+
12
+ <EmailInput
13
+ value={email}
14
+ onChange={(e) => setEmail(e.target.value)}
15
+ />
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Description |
23
+ | :--- | :--- | :--- | :--- |
24
+ | `label` | `string` | `"Email"` | Field label text. |
@@ -0,0 +1,28 @@
1
+ # Input (Text) Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { Input } from "@/pejay-ui/components/form";
11
+
12
+ <Input
13
+ label="User Name"
14
+ placeholder="Enter your user name"
15
+ prefix={<UserIcon size={16} />}
16
+ />
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Props
22
+
23
+ | Prop | Type | Default | Description |
24
+ | :--- | :--- | :--- | :--- |
25
+ | `label` | `string` | — | Label text displayed above the input. |
26
+ | `error` | `string` | — | Validation error text that highlights borders red. |
27
+ | `prefix` | `ReactNode` | — | Component to render on the left side of the input field. |
28
+ | `suffix` | `ReactNode` | — | Component to render on the right side of the input field. |
@@ -0,0 +1,24 @@
1
+ # PasswordInput Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { PasswordInput } from "@/pejay-ui/components/form";
11
+
12
+ <PasswordInput
13
+ label="Password"
14
+ placeholder="Enter secret pass"
15
+ />
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Description |
23
+ | :--- | :--- | :--- | :--- |
24
+ | `strengthBar` | `boolean` | `false` | Toggles password strength progress indicator. |
@@ -0,0 +1,24 @@
1
+ # PhoneInput Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { PhoneInput } from "@/pejay-ui/components/form";
11
+
12
+ <PhoneInput
13
+ value={phone}
14
+ onChange={(val) => setPhone(val)}
15
+ />
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Description |
23
+ | :--- | :--- | :--- | :--- |
24
+ | `label` | `string` | `"Phone Number"` | Input field label. |
@@ -0,0 +1,24 @@
1
+ # Textarea Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { Textarea } from "@/pejay-ui/components/form";
11
+
12
+ <Textarea
13
+ label="Remarks"
14
+ rows={6}
15
+ />
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Description |
23
+ | :--- | :--- | :--- | :--- |
24
+ | `rows` | `number` | `4` | Default display rows. |
@@ -0,0 +1,23 @@
1
+ # UrlInput Component
2
+
3
+ A styled form input component optimized for Tailwind.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```tsx
10
+ import { UrlInput } from "@/pejay-ui/components/form";
11
+
12
+ <UrlInput
13
+ placeholder="https://example.com"
14
+ />
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Props
20
+
21
+ | Prop | Type | Default | Description |
22
+ | :--- | :--- | :--- | :--- |
23
+ | `label` | `string` | `"URL"` | Input field label. |
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useLayoutEffect } from "react";
2
- import { cn } from "@/utils/cn";
2
+ import { cn } from "@/pejay-ui/utils/cn";
3
3
 
4
4
  /*
5
5
  * ============================================================================
@@ -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
+ }