Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)
Command palette (cmd+K) with fuzzy nav, ticket search, people lookup and action entries (new ticket, logout, show shortcuts). Opens from keyboard or user dropdown. Global keyboard shortcuts via a small useShortcut/useLeaderShortcut hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav. Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail: `e` edit, `r` focus comment composer. All guarded against firing inside text fields. @mention autocomplete in the comment composer (MentionTextarea) with arrow-key nav and Tab/Enter insert. Rendered comments and audit log rewrite @username tokens to links pointing at that user's assignee filter; unknown usernames left as plain text. Mobile sweep: TicketDetail sidebar stacks below content on <md, Settings profile grid collapses to one column, admin tables get horizontal scroll with a 640px min width, CTI 3-column grid stacks vertically on <md, New ticket severity/assignee grid same. PWA: manifest.webmanifest, SVG icon, minimal network-first service worker for the app shell (never caches /api/*), registered in production builds only. Theme-color meta + manifest link in index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function isTextField(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;
|
||||
}
|
||||
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
/**
|
||||
* Fires when the given key is pressed outside any text field.
|
||||
* Pass modifiers as part of the key spec, e.g. `mod+k` for Ctrl/Meta+K.
|
||||
* Single-char keys ignore shift unless explicitly `shift+x`.
|
||||
*/
|
||||
export function useShortcut(spec: string | string[], handler: Handler, deps: unknown[] = []) {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const specs = (Array.isArray(spec) ? spec : [spec]).map(parseSpec);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (isTextField(e.target)) {
|
||||
// Allow mod+key even inside text fields (e.g. ⌘K)
|
||||
if (!specs.some((s) => s.mod && matches(s, e))) return;
|
||||
}
|
||||
for (const s of specs) {
|
||||
if (matches(s, e)) {
|
||||
handlerRef.current(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
interface ParsedSpec {
|
||||
key: string;
|
||||
mod: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
function parseSpec(s: string): ParsedSpec {
|
||||
const parts = s.toLowerCase().split('+');
|
||||
const key = parts[parts.length - 1];
|
||||
return {
|
||||
key,
|
||||
mod: parts.includes('mod'),
|
||||
shift: parts.includes('shift'),
|
||||
};
|
||||
}
|
||||
|
||||
function matches(s: ParsedSpec, e: KeyboardEvent): boolean {
|
||||
if (e.key.toLowerCase() !== s.key) return false;
|
||||
if (s.mod !== (e.metaKey || e.ctrlKey)) return false;
|
||||
if (s.shift && !e.shiftKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a two-key "g" prefix and fires the matching handler when the second
|
||||
* key is pressed within the timeout window. Any other key cancels.
|
||||
*/
|
||||
export function useLeaderShortcut(
|
||||
leader: string,
|
||||
mapping: Record<string, () => void>,
|
||||
timeoutMs = 1000,
|
||||
) {
|
||||
const state = useRef<{ armed: boolean; timer: number | null }>({ armed: false, timer: null });
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (isTextField(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) {
|
||||
state.current.armed = false;
|
||||
return;
|
||||
}
|
||||
const key = e.key.toLowerCase();
|
||||
if (!state.current.armed) {
|
||||
if (key === leader) {
|
||||
state.current.armed = true;
|
||||
if (state.current.timer) window.clearTimeout(state.current.timer);
|
||||
state.current.timer = window.setTimeout(() => {
|
||||
state.current.armed = false;
|
||||
}, timeoutMs);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// armed: expect second key
|
||||
state.current.armed = false;
|
||||
if (state.current.timer) {
|
||||
window.clearTimeout(state.current.timer);
|
||||
state.current.timer = null;
|
||||
}
|
||||
const fn = mapping[key];
|
||||
if (fn) {
|
||||
e.preventDefault();
|
||||
fn();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leader, timeoutMs, JSON.stringify(Object.keys(mapping))]);
|
||||
}
|
||||
Reference in New Issue
Block a user