From a09c4113c1b8331590cc2e368dec680bcb4d1e04 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 16:00:42 +0300 Subject: [PATCH] feat(editor): keyboard shortcuts j/k/Enter/n per handoff Screen 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff line 438: 'Pressing j/k moves selected row; Enter opens drawer; Esc closes; n opens new-record drawer; / focuses search.' Implementation: - rowCursor state — index в visibleRecords (-1 = no focus) - j или ArrowDown → cursor++ (clamped) - k или ArrowUp → cursor-- (clamped) - Enter → setEdit({kind:'edit', record}) когда cursor valid - n → setEdit({kind:'create'}) когда canMutate - Esc — handled Radix Dialog natively через RecordDrawer - / — handled TopBar (⌘K equivalent, focuses global search input) Guards: - Skip когда target inside input/textarea/contentEditable (no conflict с typing) - Skip когда drawer/modal open (edit.kind !== 'closed' — focus в portal) - Cursor reset на page/filter change (stale index безопасности) Visual: active row получает bg-accent-bg/40 outline accent для clarity. --- .../src/routes/dictionaries.$name.tsx | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 419554c..aaa3d7d 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -331,6 +331,48 @@ function DictionaryDetail() { ? 'indeterminate' : allVisibleSelected + // === Keyboard shortcuts per handoff Screen 2 line 438 === + // j/k — move row cursor up/down + // Enter — open drawer для cursor row + // Esc — close drawer (handled Radix natively) + // n — open new-record drawer + // / — focus search (handled TopBar ⌘K shortcut; tab-style / focuses search) + // + // Cursor — index в visibleRecords. -1 когда нет focus (initial state). + const [rowCursor, setRowCursor] = useState(-1) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement + // Skip когда юзер в textarea/input — не мешаем typing. + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return + if (target && target.isContentEditable) return + // Skip когда drawer / modal открыт (focus inside Radix portal). + if (edit.kind !== 'closed') return + // Skip если другие routes — ловим только на /dictionaries/$name тут. + + if (e.key === 'j' || e.key === 'ArrowDown') { + e.preventDefault() + setRowCursor((c) => Math.min(visibleRecords.length - 1, c + 1)) + } else if (e.key === 'k' || e.key === 'ArrowUp') { + e.preventDefault() + setRowCursor((c) => Math.max(0, c - 1)) + } else if (e.key === 'Enter' && rowCursor >= 0 && rowCursor < visibleRecords.length) { + e.preventDefault() + setEdit({ kind: 'edit', record: visibleRecords[rowCursor] }) + } else if (e.key === 'n' && canMutate) { + e.preventDefault() + setEdit({ kind: 'create' }) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [visibleRecords, rowCursor, edit.kind, canMutate]) + + // Reset cursor когда visibleRecords меняется (filter, page). + useEffect(() => { + setRowCursor(-1) + }, [page, search, scopeFilter, aoi]) + const toggleSelect = (key: string) => { setSelection((prev) => { const next = new Set(prev) @@ -790,8 +832,12 @@ function DictionaryDetail() { - {visibleRecords.map((r) => ( - + {visibleRecords.map((r, idx) => ( + {canMutate && (