| name | tab-control |
| description | Guide for implementing keyboard navigation and focus indicators across macOS native app (WKWebView) and web browser versions. Use when adding focusable elements, fixing Tab key navigation, or debugging focus ring visibility issues. |
Tab Control & Focus Management
This skill documents how keyboard navigation and focus indicators work across the macOS native app and standard web browser.
The Problem
When running as a native Mac app via Swift/WKWebView, keyboard events are intercepted before reaching JavaScript. This means:
- Tab key doesn't navigate focus normally
focus-visible CSS pseudo-class doesn't trigger when focus is set programmatically
- Cmd+Enter and other shortcuts need special handling
Architecture Overview
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 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 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Tab in Rich Text Editors (ProseMirror)
When focus is inside a ProseMirror editor, Tab should trigger editor-specific behavior (like indentation) rather than moving focus to the next element.
How It Works
The __nativeFocusNext and __nativeFocusPrevious functions detect ProseMirror context:
const isInProseMirrorEditor = (): HTMLElement | null => {
const activeElement = document.activeElement as HTMLElement | null;
if (!activeElement) return null;
const proseMirrorEditor = activeElement.closest('.ProseMirror[contenteditable="true"]');
return proseMirrorEditor as HTMLElement | null;
};
const editor = isInProseMirrorEditor();
if (editor) {
const event = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
keyCode: 9,
shiftKey: false,
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(event);
return;
}
Implementing Tab Indentation in ProseMirror
For text-based lists (paragraphs with - [ ] or - markers), add keymap handlers:
export const handleTodoIndent: Command = (state, dispatch) => {
};
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),
});
Key Behavior
| 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 |
Critical Rule: Use focus Not focus-visible
Problem: 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.
className="focus-visible:outline focus-visible:outline-2"
className="focus:outline focus:outline-2 focus:outline-offset-2"
The Button component (src/components/ui/button.tsx) already uses this pattern.
Implementing Focus Indicators
For Custom Buttons/Triggers
<button
className="focus:outline-none focus:ring-2 focus:ring-offset-1"
style={{
"--tw-ring-color": currentTheme.styles.contentAccent
}}
>
Click me
</button>
For Pill/Badge Buttons (like in dialogs)
<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>
Handling Cmd+Enter
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.
For Dialogs/Forms
Use the useNativeSubmit hook:
import { useNativeSubmit } from "@/hooks/useNativeKeyboardBridge";
function MyDialog({ open, onSubmit }) {
useNativeSubmit(() => {
if (open && isValid && !loading) {
onSubmit();
}
});
return ();
}
For ProseMirror Commands (e.g., Todo Toggle)
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:
- Register your ProseMirror editor with a Cmd+Enter handler:
import { registerProseMirrorCmdEnter } from "@/hooks/useNativeKeyboardBridge";
import { toggleTodoAtLine } from "./simple-todo";
useEffect(() => {
const view = new EditorView();
const unregister = registerProseMirrorCmdEnter(view.dom as HTMLElement, () => {
return toggleTodoAtLine(view.state, view.dispatch);
});
return () => {
unregister();
view.destroy();
};
}, []);
- The handler should return
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(() => {
if (isNewNote && viewRef.current) {
const view = viewRef.current;
const unregister = registerProseMirrorCmdEnter(view.dom as HTMLElement, () => {
return toggleTodoAtLine(view.state, view.dispatch);
});
return () => { unregister(); };
}
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 });
Dialog Focus Management
- Remove X button from tab order: Set
tabIndex={-1} on close buttons
- Auto-focus first action: Add
autoFocus to Cancel or first button
- Show keyboard hint: Display Cmd+Enter shortcut under primary buttons
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSubmit}>
Save
<KeyboardIndicator keys={["cmd", "enter"]} />
</Button>
</div>
Key Files
| 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) |
Debugging Focus Issues
-
Focus ring not showing?
- Check if using
focus-visible instead of focus
- Verify element has
tabIndex if it's not naturally focusable
-
Tab not moving focus in Mac app?
- Ensure
useNativeKeyboardBridge is initialized at app root
- Check if element is in the focusable elements list
-
Tab not indenting in ProseMirror (Mac app)?
- Verify
isInProseMirrorEditor() detects the editor (check for .ProseMirror[contenteditable="true"])
- Ensure the keymap with Tab handler is added to the editor's plugins
- Check that the handler returns
true when it handles the event
-
Tab indents in browser but not Mac app?
- The synthetic KeyboardEvent must be dispatched to the editor element
- Verify
dispatchTabEvent() is called with the correct element
- Check browser console for any errors in the keyboard bridge
-
Shortcuts not firing?
- Check if a dialog is open (shortcuts are disabled when
[role="dialog"] exists)
- Verify
when condition returns true
- Check
onlyWhenActive and whether the tab is active
-
Cmd+Enter not triggering ProseMirror command (Mac app)?
- Swift dispatches CustomEvent, not KeyboardEvent - ProseMirror keymaps won't see it
- Use
registerProseMirrorCmdEnter() to register a handler for the editor
- Ensure the handler is re-registered in useEffect early return paths
- Verify
document.activeElement.closest('.ProseMirror[contenteditable="true"]') finds the editor
- Check that the handler returns
true when it handles the event
Browser vs Mac App Behavior
| 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) |
Why Cmd+Enter Needs Special Handling in ProseMirror
In the browser, Cmd+Enter fires a KeyboardEvent that ProseMirror keymaps can intercept:
export const todoKeymap = keymap({
"Cmd-Enter": toggleTodoAtLine,
});
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.