@djangocfg/ui-core 2.1.380 → 2.1.382

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.
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Human-readable hotkey formatter.
5
+ *
6
+ * Translates a `react-hotkeys-hook` combo string into the glyphs users
7
+ * expect to see in tooltips and cheat-sheets. Honours macOS conventions
8
+ * (⌘ ⌃ ⌥ ⇧) on Apple devices and falls back to text labels elsewhere.
9
+ *
10
+ * @example
11
+ * formatHotkey('mod+k') // → '⌘K' on Mac, 'Ctrl+K' elsewhere
12
+ * formatHotkey('shift+/') // → '⇧/'
13
+ * formatHotkey('escape') // → 'Esc'
14
+ * formatHotkey('g t') // → 'G then T' (chord)
15
+ */
16
+ export function formatHotkey(combo: string, opts: { mac?: boolean } = {}): string {
17
+ const mac = opts.mac ?? detectMac();
18
+
19
+ // Chord: tokens separated by spaces.
20
+ if (combo.includes(' ')) {
21
+ return combo
22
+ .split(/\s+/)
23
+ .map((part) => formatHotkey(part, { mac }))
24
+ .join(' then ');
25
+ }
26
+
27
+ // Multiple alternatives via comma — show the first.
28
+ if (combo.includes(',')) {
29
+ const first = combo.split(',')[0]?.trim();
30
+ return first ? formatHotkey(first, { mac }) : combo;
31
+ }
32
+
33
+ return combo
34
+ .split('+')
35
+ .map((token) => token.trim().toLowerCase())
36
+ .filter(Boolean)
37
+ .map((token) => formatToken(token, mac))
38
+ .join(mac ? '' : '+');
39
+ }
40
+
41
+ function detectMac(): boolean {
42
+ if (typeof navigator === 'undefined') return false;
43
+ // navigator.userAgentData is Chromium-only; fall back to platform sniff.
44
+ const ua = (navigator.userAgent || '').toLowerCase();
45
+ const platform = (navigator.platform || '').toLowerCase();
46
+ return /mac|iphone|ipad|ipod/.test(platform) || /mac os|macintosh/.test(ua);
47
+ }
48
+
49
+ function formatToken(token: string, mac: boolean): string {
50
+ switch (token) {
51
+ case 'mod':
52
+ return mac ? '⌘' : 'Ctrl';
53
+ case 'meta':
54
+ case 'cmd':
55
+ case 'command':
56
+ return mac ? '⌘' : 'Win';
57
+ case 'ctrl':
58
+ case 'control':
59
+ return mac ? '⌃' : 'Ctrl';
60
+ case 'alt':
61
+ case 'option':
62
+ return mac ? '⌥' : 'Alt';
63
+ case 'shift':
64
+ return mac ? '⇧' : 'Shift';
65
+ case 'escape':
66
+ case 'esc':
67
+ return 'Esc';
68
+ case 'enter':
69
+ case 'return':
70
+ return mac ? '⏎' : 'Enter';
71
+ case 'tab':
72
+ return mac ? '⇥' : 'Tab';
73
+ case 'space':
74
+ return mac ? 'Space' : 'Space';
75
+ case 'backspace':
76
+ return mac ? '⌫' : 'Backspace';
77
+ case 'delete':
78
+ case 'del':
79
+ return mac ? '⌦' : 'Del';
80
+ case 'arrowup':
81
+ case 'up':
82
+ return '↑';
83
+ case 'arrowdown':
84
+ case 'down':
85
+ return '↓';
86
+ case 'arrowleft':
87
+ case 'left':
88
+ return '←';
89
+ case 'arrowright':
90
+ case 'right':
91
+ return '→';
92
+ default:
93
+ if (token.length === 1) return token.toUpperCase();
94
+ return token.charAt(0).toUpperCase() + token.slice(1);
95
+ }
96
+ }
@@ -2,3 +2,13 @@
2
2
 
3
3
  export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
4
4
  export type { UseHotkeyOptions, HotkeyCallback, Keys, HotkeyRefType } from './useHotkey';
5
+
6
+ export { formatHotkey } from './formatHotkey';
7
+ export { useHotkeyChord, type UseHotkeyChordOptions } from './useHotkeyChord';
8
+ export {
9
+ useHotkeyHelp,
10
+ useRegisterHotkey,
11
+ getRegisteredHotkeys,
12
+ registerHotkey,
13
+ type RegisteredHotkey,
14
+ } from './useHotkeyHelp';
@@ -1,26 +1,54 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect } from 'react';
3
4
  import { Options as HotkeysOptions, useHotkeys } from 'react-hotkeys-hook';
4
5
  import type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
5
6
 
7
+ import { registerHotkey } from './useHotkeyHelp';
8
+
6
9
  /** Ref type for hotkey target element */
7
10
  export type HotkeyRefType<T> = T | null;
8
11
 
9
12
  /**
10
- * Options for the useHotkey hook
13
+ * Options for the useHotkey hook.
14
+ *
15
+ * Most of the time you only need `{ inInput }` or nothing — sensible
16
+ * defaults are derived from the key combo (see below).
11
17
  */
12
18
  export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
13
19
  /** Whether the hotkey is enabled (default: true) */
14
20
  enabled?: boolean;
15
- /** Scope for the hotkey - useful for context-specific shortcuts */
21
+ /** Scope for the hotkey useful for context-specific shortcuts */
16
22
  scope?: string;
17
23
  /** Only trigger when focus is within a specific element */
18
24
  scopes?: string[];
19
25
  /** Prevent default browser behavior */
20
26
  preventDefault?: boolean;
21
- /** Enable in input fields and textareas */
27
+ /**
28
+ * Whether the shortcut should also fire when focus is inside an input,
29
+ * textarea, select, or contenteditable element.
30
+ *
31
+ * Default policy (when not specified):
32
+ * - Modifier-bearing combos (`cmd+*`, `ctrl+*`, `alt+*`, `shift+*`)
33
+ * → `true`. Global app shortcuts (⌘K, Ctrl+S, ⌘/) must work no
34
+ * matter where the user is.
35
+ * - `escape` → `true`. Escape semantics (blur / close) are universal.
36
+ * - Bare single-character keys (`/`, `?`, `j`, …) → `false`. They
37
+ * would otherwise eat keystrokes the user is typing into inputs.
38
+ * - Function keys, arrows, etc. → `false`.
39
+ *
40
+ * Pass `true` or `false` explicitly to override.
41
+ */
42
+ inInput?: boolean;
43
+ /**
44
+ * @deprecated Use `inInput` instead. Forwarded to react-hotkeys-hook
45
+ * when explicitly set; otherwise derived from `inInput` / policy.
46
+ */
22
47
  enableOnFormTags?: boolean | readonly ('input' | 'textarea' | 'select')[];
23
- /** Enable when contentEditable element is focused */
48
+ /**
49
+ * @deprecated Use `inInput` instead. Same forwarding rules as
50
+ * `enableOnFormTags`.
51
+ */
24
52
  enableOnContentEditable?: boolean;
25
53
  /** Split key for multiple hotkey combinations (default: ',') */
26
54
  splitKey?: string;
@@ -32,40 +60,24 @@ export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
32
60
  }
33
61
 
34
62
  /**
35
- * Simple wrapper hook for react-hotkeys-hook
36
- *
37
- * @example
38
- * // Single key
39
- * useHotkey('escape', () => closeModal());
63
+ * Wrapper around react-hotkeys-hook with an opinionated `inInput` policy.
40
64
  *
41
65
  * @example
42
- * // Key combination
43
- * useHotkey('ctrl+s', (e) => {
44
- * e.preventDefault();
45
- * saveDocument();
46
- * });
66
+ * // ⌘K palette — works everywhere, including inside text fields
67
+ * useHotkey('mod+k', openPalette);
47
68
  *
48
69
  * @example
49
- * // Multiple keys (any of them will trigger)
50
- * useHotkey(['ArrowLeft', '['], () => goToPrevious());
51
- * useHotkey(['ArrowRight', ']'], () => goToNext());
70
+ * // `/` focuses search auto-disabled inside text fields so typing
71
+ * // a `/` into a textarea doesn't yank focus away
72
+ * useHotkey('/', focusSearch);
52
73
  *
53
74
  * @example
54
- * // With options
55
- * useHotkey('/', () => focusSearch(), {
56
- * preventDefault: true,
57
- * enableOnFormTags: false,
58
- * description: 'Focus search input'
59
- * });
75
+ * // Escape — defaults to fire in text fields too (blur / close pattern)
76
+ * useHotkey('escape', closeModal);
60
77
  *
61
78
  * @example
62
- * // Scoped hotkeys
63
- * useHotkey('delete', () => deleteItem(), { scopes: ['list-view'] });
64
- *
65
- * @param keys - Hotkey or array of hotkeys (e.g., 'ctrl+s', 'ArrowLeft', ['[', 'ArrowLeft'])
66
- * @param callback - Function to call when hotkey is pressed
67
- * @param options - Configuration options
68
- * @returns Ref callback to attach to element for scoped hotkeys
79
+ * // Explicit opt-in for a bare key inside inputs
80
+ * useHotkey('?', openHelp, { inInput: true });
69
81
  */
70
82
  export function useHotkey<T extends HTMLElement = HTMLElement>(
71
83
  keys: Keys,
@@ -74,13 +86,35 @@ export function useHotkey<T extends HTMLElement = HTMLElement>(
74
86
  ): (instance: HotkeyRefType<T>) => void {
75
87
  const {
76
88
  enabled = true,
77
- preventDefault = false,
78
- enableOnFormTags = false,
79
- enableOnContentEditable = false,
80
- description: _description,
89
+ preventDefault: explicitPreventDefault,
90
+ inInput,
91
+ enableOnFormTags: explicitFormTags,
92
+ enableOnContentEditable: explicitContentEditable,
93
+ description,
94
+ scope,
81
95
  ...restOptions
82
96
  } = options;
83
97
 
98
+ const policyAllowsInInput = resolveInInput(keys, inInput);
99
+ // Smart default — modifier combos preventDefault by default
100
+ // (cmd+s, cmd+k, etc. would otherwise fall through to the browser).
101
+ const preventDefault = explicitPreventDefault ?? hasModifier(keys);
102
+
103
+ // Register in the cheat-sheet help registry when described.
104
+ useEffect(() => {
105
+ if (!description) return;
106
+ return registerHotkey({
107
+ combo: normalizeKeys(keys).join(','),
108
+ description,
109
+ scope,
110
+ });
111
+ }, [keys, description, scope]);
112
+
113
+ const enableOnFormTags =
114
+ explicitFormTags !== undefined ? explicitFormTags : policyAllowsInInput;
115
+ const enableOnContentEditable =
116
+ explicitContentEditable !== undefined ? explicitContentEditable : policyAllowsInInput;
117
+
84
118
  return useHotkeys<T>(
85
119
  keys,
86
120
  (event, handler) => {
@@ -98,6 +132,44 @@ export function useHotkey<T extends HTMLElement = HTMLElement>(
98
132
  );
99
133
  }
100
134
 
135
+ /**
136
+ * Decide whether a given key combo should fire when focus is inside a
137
+ * text input. Honours the caller's explicit `inInput` first, otherwise
138
+ * applies the policy described on `UseHotkeyOptions.inInput`.
139
+ */
140
+ function resolveInInput(keys: Keys, explicit?: boolean): boolean {
141
+ if (explicit !== undefined) return explicit;
142
+
143
+ const list = normalizeKeys(keys);
144
+ // If ANY of the listed combos qualifies as "input-safe", treat the
145
+ // whole binding as input-safe — the caller declared them as
146
+ // alternatives, so the more permissive policy wins.
147
+ return list.some(isInputSafeByPolicy);
148
+ }
149
+
150
+ function normalizeKeys(keys: Keys): string[] {
151
+ if (Array.isArray(keys)) return keys.map((k) => String(k).toLowerCase());
152
+ return [String(keys).toLowerCase()];
153
+ }
154
+
155
+ const ESCAPE_ALIASES = new Set(['escape', 'esc']);
156
+ const MODIFIER_ALIASES = ['mod+', 'meta+', 'cmd+', 'ctrl+', 'control+', 'alt+', 'option+', 'shift+'];
157
+
158
+ function isInputSafeByPolicy(combo: string): boolean {
159
+ if (ESCAPE_ALIASES.has(combo)) return true;
160
+ return MODIFIER_ALIASES.some((mod) => combo.startsWith(mod));
161
+ }
162
+
163
+ /**
164
+ * Does the combo include a modifier key? Used to default `preventDefault`
165
+ * so global app shortcuts (⌘S, ⌘K, …) never fall through to the browser.
166
+ */
167
+ function hasModifier(keys: Keys): boolean {
168
+ return normalizeKeys(keys).some((combo) =>
169
+ MODIFIER_ALIASES.some((mod) => combo.startsWith(mod)),
170
+ );
171
+ }
172
+
101
173
  // Re-export useful utilities from react-hotkeys-hook
102
174
  export { useHotkeysContext, HotkeysProvider, isHotkeyPressed } from 'react-hotkeys-hook';
103
175
 
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ export interface UseHotkeyChordOptions {
6
+ /** Whether the chord is active. @default true */
7
+ enabled?: boolean;
8
+ /** Max delay between consecutive keys, in ms. @default 800 */
9
+ window?: number;
10
+ /**
11
+ * Fire even when focus is inside an input / textarea / contenteditable.
12
+ * @default false — chord sequences shouldn't hijack typing
13
+ */
14
+ enableOnFormTags?: boolean;
15
+ /** Prevent default browser behaviour on each key. @default false */
16
+ preventDefault?: boolean;
17
+ }
18
+
19
+ const FORM_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
20
+
21
+ function isEditableTarget(el: EventTarget | null): boolean {
22
+ if (!(el instanceof HTMLElement)) return false;
23
+ if (FORM_TAGS.has(el.tagName)) return true;
24
+ if (el.isContentEditable) return true;
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Linear-style chord shortcuts — fire `callback` when the user presses
30
+ * a sequence of bare keys within a time window.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * useHotkeyChord(['g', 't'], () => navigate('/tasks'));
35
+ * useHotkeyChord(['g', 'i'], () => navigate('/inbox'), { window: 1200 });
36
+ * ```
37
+ *
38
+ * Each step is a single bare key (no modifiers). Pressing any
39
+ * non-sequence key resets progress, so partially-typed sequences don't
40
+ * fire accidentally.
41
+ */
42
+ export function useHotkeyChord(
43
+ keys: readonly string[],
44
+ callback: (event: KeyboardEvent) => void,
45
+ options: UseHotkeyChordOptions = {},
46
+ ): void {
47
+ const { enabled = true, window: chordWindow = 800, enableOnFormTags = false, preventDefault = false } = options;
48
+ const callbackRef = useRef(callback);
49
+ callbackRef.current = callback;
50
+
51
+ useEffect(() => {
52
+ if (!enabled) return;
53
+ if (typeof window === 'undefined') return;
54
+ if (!keys.length) return;
55
+
56
+ const sequence = keys.map((k) => k.toLowerCase());
57
+ let progress = 0;
58
+ let timer: ReturnType<typeof setTimeout> | null = null;
59
+
60
+ const reset = () => {
61
+ progress = 0;
62
+ if (timer) {
63
+ clearTimeout(timer);
64
+ timer = null;
65
+ }
66
+ };
67
+
68
+ const onKey = (e: KeyboardEvent) => {
69
+ if (!enableOnFormTags && isEditableTarget(e.target)) return;
70
+ // Chords don't carry modifiers.
71
+ if (e.metaKey || e.ctrlKey || e.altKey) return reset();
72
+
73
+ const expected = sequence[progress];
74
+ if (!expected) return;
75
+ const pressed = e.key.toLowerCase();
76
+ if (pressed !== expected) return reset();
77
+
78
+ if (preventDefault) e.preventDefault();
79
+ progress++;
80
+ if (progress >= sequence.length) {
81
+ callbackRef.current(e);
82
+ reset();
83
+ return;
84
+ }
85
+
86
+ if (timer) clearTimeout(timer);
87
+ timer = setTimeout(reset, chordWindow);
88
+ };
89
+
90
+ window.addEventListener('keydown', onKey);
91
+ return () => {
92
+ window.removeEventListener('keydown', onKey);
93
+ if (timer) clearTimeout(timer);
94
+ };
95
+ }, [keys.join('|'), enabled, chordWindow, enableOnFormTags, preventDefault]);
96
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * Module-level store of every hotkey that registered itself with a
7
+ * `description`. Lets you render a `?` cheat-sheet without prop-drilling.
8
+ */
9
+ export interface RegisteredHotkey {
10
+ /** Key combo (raw string passed to `useHotkey` — format with `formatHotkey()` for display). */
11
+ combo: string;
12
+ /** Human-readable purpose. */
13
+ description: string;
14
+ /** Optional scope label for grouping (e.g. `'chat'`, `'global'`). */
15
+ scope?: string;
16
+ }
17
+
18
+ const registry = new Map<symbol, RegisteredHotkey>();
19
+ const listeners = new Set<() => void>();
20
+
21
+ function notify() {
22
+ for (const cb of listeners) cb();
23
+ }
24
+
25
+ /**
26
+ * Register a hotkey entry while a component is mounted. Internal —
27
+ * called by `useHotkey` when a `description` is provided.
28
+ */
29
+ export function registerHotkey(entry: RegisteredHotkey): () => void {
30
+ const key = Symbol(entry.combo);
31
+ registry.set(key, entry);
32
+ notify();
33
+ return () => {
34
+ registry.delete(key);
35
+ notify();
36
+ };
37
+ }
38
+
39
+ /** Read the live list of registered hotkeys. SSR-safe. */
40
+ export function useHotkeyHelp(): RegisteredHotkey[] {
41
+ return useSyncExternalStore(
42
+ (cb) => {
43
+ listeners.add(cb);
44
+ return () => {
45
+ listeners.delete(cb);
46
+ };
47
+ },
48
+ () => Array.from(registry.values()),
49
+ () => [],
50
+ );
51
+ }
52
+
53
+ /** Imperative read for non-React consumers (debug panels, etc.). */
54
+ export function getRegisteredHotkeys(): RegisteredHotkey[] {
55
+ return Array.from(registry.values());
56
+ }
57
+
58
+ /**
59
+ * Hook variant — call alongside `useHotkey` when the binding lives
60
+ * outside the hook (e.g. raw `window.addEventListener` consumers that
61
+ * still want to appear in the cheat sheet).
62
+ */
63
+ export function useRegisterHotkey(entry: RegisteredHotkey | null): void {
64
+ useEffect(() => {
65
+ if (!entry) return;
66
+ return registerHotkey(entry);
67
+ }, [entry?.combo, entry?.description, entry?.scope]);
68
+ }
@@ -19,6 +19,7 @@ export * from './theme';
19
19
  export * from './time';
20
20
  export * from './events';
21
21
  export * from './hotkey';
22
+ export * from './audio';
22
23
  export * from './debug';
23
24
 
24
25
  // ----------------------------------------------------------------------------