diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx
index a7958a5..8ee5b71 100644
--- a/src/food-market.web/src/components/AppLayout.tsx
+++ b/src/food-market.web/src/components/AppLayout.tsx
@@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
+import { ShortcutsOverlay } from './ShortcutsOverlay'
interface MeResponse {
sub: string
@@ -269,6 +270,9 @@ export function AppLayout() {
+ {/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
+ 'Esc' и '?', не блокирует ввод в инпутах. */}
+
)
}
diff --git a/src/food-market.web/src/components/SearchBar.tsx b/src/food-market.web/src/components/SearchBar.tsx
index d3df95a..0e7e225 100644
--- a/src/food-market.web/src/components/SearchBar.tsx
+++ b/src/food-market.web/src/components/SearchBar.tsx
@@ -1,3 +1,4 @@
+import { forwardRef } from 'react'
import { Search } from 'lucide-react'
interface SearchBarProps {
@@ -6,11 +7,19 @@ interface SearchBarProps {
placeholder?: string
}
-export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
+/**
+ * Поиск с иконкой. forwardRef нужен, чтобы list-страница могла фокуснуть
+ * input по хоткею `/` (см. useShortcuts на ProductsPage и др.).
+ */
+export const SearchBar = forwardRef(function SearchBar(
+ { value, onChange, placeholder = 'Поиск…' },
+ ref,
+) {
return (
onChange(e.target.value)}
@@ -19,4 +28,4 @@ export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: Se
/>
)
-}
+})
diff --git a/src/food-market.web/src/components/ShortcutsOverlay.tsx b/src/food-market.web/src/components/ShortcutsOverlay.tsx
new file mode 100644
index 0000000..21a77b7
--- /dev/null
+++ b/src/food-market.web/src/components/ShortcutsOverlay.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react'
+import { Keyboard, X } from 'lucide-react'
+import { useShortcuts } from '@/lib/useShortcuts'
+
+/**
+ * Глобальный «?» overlay — открывается клавишей `?`. Показывает текущие
+ * горячие клавиши приложения: ничего hierarchical, просто плоский список
+ * по разделам (Edit-страницы / List-страницы / Глобально).
+ *
+ * Маунтим один раз в AppLayout, чтобы был доступен везде внутри tenant'a.
+ */
+export function ShortcutsOverlay() {
+ const [open, setOpen] = useState(false)
+ useShortcuts({
+ '?': () => setOpen(o => !o),
+ 'Escape': () => { if (open) setOpen(false) },
+ })
+
+ // Закрытие по клику на оверлей
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open])
+
+ if (!open) return null
+
+ return (
+ setOpen(false)}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="shortcuts-title"
+ >
+
e.stopPropagation()}
+ >
+
+
+
+
Горячие клавиши
+
+
+
+
+
+
+
+
+
+ Нажми ? в любой момент, чтобы открыть эту шпаргалку.
+
+
+
+ )
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function Row({ label, keys }: { label: string; keys: string[] }) {
+ return (
+
+ {label}
+
+ {keys.map((k, i) => (
+
+ {i > 0 && +}
+ {k}
+
+ ))}
+
+
+ )
+}
+
+function Kbd({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/food-market.web/src/lib/useShortcuts.ts b/src/food-market.web/src/lib/useShortcuts.ts
new file mode 100644
index 0000000..fd14a74
--- /dev/null
+++ b/src/food-market.web/src/lib/useShortcuts.ts
@@ -0,0 +1,77 @@
+import { useEffect, useRef } from 'react'
+
+/**
+ * Регистрирует глобальные клавиатурные сокращения. Поддерживаемые формы:
+ * - 'Escape' | 'Enter' | 'F1' …
+ * - 'mod+s' — Ctrl+S на Linux/Win, Cmd+S на Mac
+ * - 'mod+shift+s'
+ * - 'n', '/', '?' (одиночные клавиши БЕЗ модификаторов)
+ *
+ * Особенности:
+ * - Одиночные клавиши срабатывают только если фокус НЕ в input/textarea/
+ * contenteditable (чтобы не ломать набор текста). 'Escape' и mod+*
+ * срабатывают всегда.
+ * - preventDefault() вызывается автоматически — handler решает только что
+ * делать, без боли с e.preventDefault().
+ * - При unmount хук снимает listener.
+ *
+ * Использование:
+ * useShortcuts({
+ * 'mod+s': () => save.mutate(),
+ * 'Escape': () => navigate(-1),
+ * '/': () => searchRef.current?.focus(),
+ * 'n': () => navigate('/catalog/products/new'),
+ * })
+ */
+type Handler = (e: KeyboardEvent) => void
+type Map = Record
+
+const isTypingTarget = (el: EventTarget | null): boolean => {
+ if (!(el instanceof HTMLElement)) return false
+ const tag = el.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
+ if (el.isContentEditable) return true
+ return false
+}
+
+const matches = (e: KeyboardEvent, spec: string): boolean => {
+ const parts = spec.toLowerCase().split('+').map(s => s.trim())
+ const wantMod = parts.includes('mod')
+ const wantShift = parts.includes('shift')
+ const wantAlt = parts.includes('alt')
+ const key = parts[parts.length - 1]
+
+ const ctrlOrMeta = e.ctrlKey || e.metaKey
+ if (wantMod !== ctrlOrMeta) return false
+ if (wantShift !== e.shiftKey) return false
+ if (wantAlt !== e.altKey) return false
+
+ // 'mod+s' → e.key === 's' (lowercase)
+ return e.key.toLowerCase() === key
+}
+
+export function useShortcuts(map: Map, enabled = true) {
+ // Держим map в ref, чтобы не перерегистрировать listener на каждый рендер
+ // (handlers часто захватывают свежие props/state — но useEffect deps
+ // делает их стабильными).
+ const ref = useRef(map)
+ ref.current = map
+
+ useEffect(() => {
+ if (!enabled) return
+ const onKey = (e: KeyboardEvent) => {
+ for (const [spec, handler] of Object.entries(ref.current)) {
+ const isBareKey = !spec.includes('+') && spec.length === 1
+ // Бэр-клавиши ('n', '/', '?') не должны срабатывать в input'ах.
+ if (isBareKey && isTypingTarget(e.target)) continue
+ if (matches(e, spec)) {
+ e.preventDefault()
+ handler(e)
+ return
+ }
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [enabled])
+}
diff --git a/src/food-market.web/src/pages/CounterpartiesPage.tsx b/src/food-market.web/src/pages/CounterpartiesPage.tsx
index a28ea1e..a150aad 100644
--- a/src/food-market.web/src/pages/CounterpartiesPage.tsx
+++ b/src/food-market.web/src/pages/CounterpartiesPage.tsx
@@ -1,6 +1,7 @@
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation'
+import { useShortcuts } from '@/lib/useShortcuts'
import { Plus, Trash2, Users } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
@@ -57,6 +58,13 @@ export function CounterpartiesPage() {
const [form, setForm] = useState
-
+