Guide for implementing keyboard navigation and focus indicators across macOS native app (WKWebView) and web browser versions...
This skill documents how keyboard navigation and focus indicators work across the macOS native app and standard web browser.
When running as a native Mac app via Swift/WKWebView, keyboard events are intercepted before reaching JavaScript. This means:
focus-visible CSS pseudo-class doesn't trigger when focus is set programmaticallyāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Swift (AppDelegate.swift) ā
ā - NSEvent.addLocalMonitorForEvents intercepts keys ā
ā - Calls dispatchKeyToWebView() for handled keys ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
ā¼ evaluateJavaScript
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Global Handlers (useNativeKeyboardBridge.ts) ā
ā - window.__nativeFocusNext() - Tab navigation ā
ā - window.__nativeFocusPrevious() - Shift+Tab navigation ā
ā - Checks context: ProseMirror editor vs regular elements ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
āāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāā
ā¼ ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā ProseMirror Editor ā ā Regular Elements ā
ā - Dispatches synthetic ā ā - Moves focus to next/prev ā
ā Tab KeyboardEvent ā ā focusable element ā
ā - Editor handles ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā indent/outdent ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā
When focus is inside a ProseMirror editor, Tab should trigger editor-specific behavior (like indentation) rather than moving focus to the next element.
The __nativeFocusNext and __nativeFocusPrevious functions detect ProseMirror context:
// Check if focus is in a ProseMirror editor
const isInProseMirrorEditor = (): HTMLElement | null => {
const activeElement = document.activeElement as HTMLElement | null;
if (!activeElement) return null;
// Check if active element or parent is ProseMirror contenteditable
const proseMirrorEditor = activeElement.closest('.ProseMirror[contenteditable="true"]');
return proseMirrorEditor as HTMLElement | null;
};
// In focusNext():
const editor = isInProseMirrorEditor();
if (editor) {
// Dispatch synthetic Tab event - let ProseMirror handle it
const event = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
keyCode: 9,
shiftKey: false, // or true for Shift-Tab
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(event);
return;
}
// Otherwise, move focus normally...
For text-based lists (paragraphs with - [ ] or - markers), add keymap handlers:
// In simple-todo.ts or similar
export const handleTodoIndent: Command = (state, dispatch) => {
// 1. Check if on a todo/bullet line
// 2. Check if there's a valid parent item above
// 3. Add indentation (e.g., 2 spaces) to line start
// 4. Return true to indicate handled
};
export const todoKeymap = keymap({
"Tab": handleTodoIndent,
"Shift-Tab": handleTodoOutdent,
});
For ProseMirror's native list nodes, use prosemirror-schema-list:
import { sinkListItem, liftListItem } from "prosemirror-schema-list";
const listKeymap = keymap({
"Tab": sinkListItem(schema.nodes.list_item),
"Shift-Tab": liftListItem(schema.nodes.list_item),
});
| Context | Tab | Shift-Tab |
|---|---|---|
| ProseMirror on todo/bullet | Indents item | Outdents item |
| ProseMirror on regular text | No action (not handled) | No action |
| Button/input/other element | Moves focus forward | Moves focus backward |
focus Not focus-visibleProblem: When the native keyboard bridge calls element.focus() programmatically, browsers don't trigger the focus-visible pseudo-class because they don't detect "keyboard navigation".
Solution: Always use focus: instead of focus-visible: for focus indicators.
// BAD - Won't show focus ring in Mac app
className="focus-visible:outline focus-visible:outline-2"
// GOOD - Always shows focus ring when focused
className="focus:outline focus:outline-2 focus:outline-offset-2"
The Button component (src/components/ui/button.tsx) already uses this pattern.
<button
className="focus:outline-none focus:ring-2 focus:ring-offset-1"
style={{
// Use theme color for the ring
"--tw-ring-color": currentTheme.styles.contentAccent
}}
>
Click me
</button>
<button
className="px-3 py-1.5 rounded-md transition-colors hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-offset-1"
style={{
backgroundColor: styles.surfaceTertiary,
color: styles.contentPrimary,
}}
>
Status
</button>
Swift intercepts Cmd+Enter at the native level and dispatches a CustomEvent('nativeSubmit') to JavaScript. This means ProseMirror keymaps (which expect a KeyboardEvent) never see it.
Use the useNativeSubmit hook:
import { useNativeSubmit } from "@/hooks/useNativeKeyboardBridge";
function MyDialog({ open, onSubmit }) {
useNativeSubmit(() => {
if (open && isValid && !loading) {
onSubmit();
}
});
return (/* dialog content */);
}
ProseMirror keymaps listen for KeyboardEvents, but Swift dispatches a CustomEvent. The solution is to register a handler that gets called when Cmd+Enter is pressed while the editor has focus.
Architecture:
User presses Cmd+Enter
ā
Swift intercepts (native level)
ā
Swift dispatches CustomEvent('nativeSubmit')
ā
useNativeKeyboardBridge intercept listener
ā
Check: Is focus in a registered ProseMirror editor?
ā
YES: Call registered handler (e.g., toggleTodoAtLine)
ā stopImmediatePropagation() to prevent dialog handlers
NO: Let event propagate to useNativeSubmit dialog handlers
Implementation:
import { registerProseMirrorCmdEnter } from "@/hooks/useNativeKeyboardBridge";
import { toggleTodoAtLine } from "./simple-todo";
// In your ProseMirror initialization useEffect:
useEffect(() => {
const view = new EditorView(/* ... */);
// Register Cmd+Enter handler for this editor
const unregister = registerProseMirrorCmdEnter(view.dom as HTMLElement, () => {
return toggleTodoAtLine(view.state, view.dispatch);
});
return () => {
unregister();
view.destroy();
};
}, []);
true if it handled the event, false otherwise.Important: React useEffect cleanup re-registration
If your useEffect has early return paths (e.g., reusing an existing editor), the cleanup from the previous render will unregister the handler. You must re-register in those paths:
useEffect(() => {
// Early return path that reuses existing editor
if (isNewNote && viewRef.current) {
// ... update editor content ...
// Re-register handler (cleanup from previous render unregistered it)
const view = viewRef.current;
const unregister = registerProseMirrorCmdEnter(view.dom as HTMLElement, () => {
return toggleTodoAtLine(view.state, view.dispatch);
});
return () => { unregister(); };
}
// Normal path that creates new editor
const view = new EditorView(/* ... */);
const unregister = registerProseMirrorCmdEnter(view.dom as HTMLElement, () => {
return toggleTodoAtLine(view.state, view.dispatch);
});
return () => {
unregister();
view.destroy();
};
}, [dependencies]);
## Making Containers Keyboard Navigable
For lists/tables that need arrow key navigation, add `tabIndex={0}` and `onKeyDown`:
```tsx
// Example from ProjectBrowserView.tsx
<Table
ref={tableRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className="outline-none"
>
For Kanban-style views using global shortcuts via useKeyboardShortcuts, ensure the when conditions don't block navigation:
useKeyboardShortcuts([
{
id: 'navigate-down',
combo: { key: 'ArrowDown' },
handler: navigateDown,
when: () => items.length > 0 && document.activeElement !== searchInputRef.current,
},
], { onlyWhenActive: true });
tabIndex={-1} on close buttonsautoFocus to Cancel or first button<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSubmit}>
Save
<KeyboardIndicator keys={["cmd", "enter"]} />
</Button>
</div>
| File | Purpose |
|---|---|
src/hooks/useNativeKeyboardBridge.ts |
Global focus navigation, ProseMirror detection, Cmd+Enter registry |
src/features/notes/simple-todo.ts |
Todo/bullet Tab indent handlers + toggleTodoAtLine command |
src/features/notes/note-view.tsx |
ProseMirror editor setup + Cmd+Enter handler registration |
src/components/ui/button.tsx |
Button with proper focus styling |
docs/mac-app-keyboard-shortcuts.md |
Full keyboard bridge documentation |
mac-app/macos-host/Sources/AppDelegate.swift |
Swift keyboard interception (dispatches nativeSubmit event) |
Focus ring not showing?
focus-visible instead of focustabIndex if it's not naturally focusableTab not moving focus in Mac app?
useNativeKeyboardBridge is initialized at app rootTab not indenting in ProseMirror (Mac app)?
isInProseMirrorEditor() detects the editor (check for .ProseMirror[contenteditable="true"])true when it handles the eventTab indents in browser but not Mac app?
dispatchTabEvent() is called with the correct elementShortcuts not firing?
[role="dialog"] exists)when condition returns trueonlyWhenActive and whether the tab is activeCmd+Enter not triggering ProseMirror command (Mac app)?
registerProseMirrorCmdEnter() to register a handler for the editordocument.activeElement.closest('.ProseMirror[contenteditable="true"]') finds the editortrue when it handles the event| Feature | Browser | Mac App |
|---|---|---|
| Tab navigation | Native | Via __nativeFocusNext |
| Tab in ProseMirror | Native KeyboardEvent | Synthetic KeyboardEvent via bridge |
| focus-visible | Works | Doesn't trigger |
| Cmd+Enter in dialogs | KeyboardEvent | CustomEvent 'nativeSubmit' ā useNativeSubmit |
| Cmd+Enter in ProseMirror | KeyboardEvent ā keymap | CustomEvent ā registerProseMirrorCmdEnter handler |
| Arrow keys | Native | Native (not intercepted) |
In the browser, Cmd+Enter fires a KeyboardEvent that ProseMirror keymaps can intercept:
// This works in browser but NOT in Mac app
export const todoKeymap = keymap({
"Cmd-Enter": toggleTodoAtLine, // Never fires in Mac app!
});
In the Mac app, Swift intercepts Cmd+Enter before it reaches JavaScript and dispatches a CustomEvent instead. The solution is the registerProseMirrorCmdEnter registry which intercepts the CustomEvent and calls your handler directly.