Skip to content

Commit 0acec3f

Browse files
authored
feat: keyboard focus navigation for lists (#422)
Add Tab key focus navigation, Ctrl+J/K/N/P shortcuts, and arrow key support to connection list, quick switcher, and database switcher. Auto-focus search field on open. Extract NSView+Focus extension. Fix pre-existing test compilation errors.
1 parent 5e119ca commit 0acec3f

16 files changed

+268
-64
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Keyboard focus navigation (Tab, Ctrl+J/K/N/P, arrow keys) for connection list, quick switcher, and database switcher
13+
1014
## [0.22.1] - 2026-03-22
1115

1216
### Added

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ extension AppDelegate {
162162
window.isOpaque = false
163163
window.backgroundColor = .clear
164164
window.titlebarAppearsTransparent = true
165+
166+
window.makeKeyAndOrderFront(nil)
167+
168+
if let textField = window.contentView?.firstEditableTextField() {
169+
window.makeFirstResponder(textField)
170+
}
165171
}
166172

167173
private func configureConnectionFormWindowStyle(_ window: NSWindow) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// NSView+Focus.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
8+
extension NSView {
9+
func firstEditableTextField() -> NSTextField? {
10+
if let textField = self as? NSTextField, textField.isEditable {
11+
return textField
12+
}
13+
for subview in subviews {
14+
if let found = subview.firstEditableTextField() {
15+
return found
16+
}
17+
}
18+
return nil
19+
}
20+
}

TablePro/Views/Connection/WelcomeWindowView.swift

Lines changed: 95 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import SwiftUI
1414

1515
struct WelcomeWindowView: View {
1616
private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeWindowView")
17+
18+
private enum FocusField {
19+
case search
20+
case connectionList
21+
}
22+
1723
private let storage = ConnectionStorage.shared
1824
private let groupStorage = GroupStorage.shared
1925
private let dbManager = DatabaseManager.shared
@@ -26,7 +32,8 @@ struct WelcomeWindowView: View {
2632
@State private var connectionToDelete: DatabaseConnection?
2733
@State private var showDeleteConfirmation = false
2834
@State private var hoveredConnectionId: UUID?
29-
@State private var selectedConnectionId: UUID? // For keyboard navigation
35+
@State private var selectedConnectionId: UUID?
36+
@FocusState private var focus: FocusField?
3037
@State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding()
3138
@State private var groups: [ConnectionGroup] = []
3239
@State private var collapsedGroupIds: Set<UUID> = {
@@ -271,6 +278,37 @@ struct WelcomeWindowView: View {
271278
TextField("Search for connection...", text: $searchText)
272279
.textFieldStyle(.plain)
273280
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
281+
.focused($focus, equals: .search)
282+
.onKeyPress(.return) {
283+
if let id = selectedConnectionId,
284+
let connection = connections.first(where: { $0.id == id })
285+
{
286+
connectToDatabase(connection)
287+
}
288+
return .handled
289+
}
290+
.onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in
291+
guard keyPress.modifiers.contains(.control) else { return .ignored }
292+
moveToNextConnection()
293+
focus = .connectionList
294+
return .handled
295+
}
296+
.onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in
297+
guard keyPress.modifiers.contains(.control) else { return .ignored }
298+
moveToPreviousConnection()
299+
focus = .connectionList
300+
return .handled
301+
}
302+
.onKeyPress(.downArrow) {
303+
moveToNextConnection()
304+
focus = .connectionList
305+
return .handled
306+
}
307+
.onKeyPress(.upArrow) {
308+
moveToPreviousConnection()
309+
focus = .connectionList
310+
return .handled
311+
}
274312
}
275313
.padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.sm)
276314
.padding(.vertical, 6)
@@ -311,57 +349,62 @@ struct WelcomeWindowView: View {
311349
/// - Return key: connects to selected row
312350
/// - Arrow keys: native keyboard navigation
313351
private var connectionList: some View {
314-
List(selection: $selectedConnectionId) {
315-
ForEach(ungroupedConnections) { connection in
316-
connectionRow(for: connection)
317-
}
318-
.onMove { from, to in
319-
guard searchText.isEmpty else { return }
320-
moveUngroupedConnections(from: from, to: to)
321-
}
352+
ScrollViewReader { proxy in
353+
List(selection: $selectedConnectionId) {
354+
ForEach(ungroupedConnections) { connection in
355+
connectionRow(for: connection)
356+
}
357+
.onMove { from, to in
358+
guard searchText.isEmpty else { return }
359+
moveUngroupedConnections(from: from, to: to)
360+
}
322361

323-
ForEach(activeGroups) { group in
324-
Section {
325-
if !collapsedGroupIds.contains(group.id) {
326-
ForEach(connections(in: group)) { connection in
327-
connectionRow(for: connection)
362+
ForEach(activeGroups) { group in
363+
Section {
364+
if !collapsedGroupIds.contains(group.id) {
365+
ForEach(connections(in: group)) { connection in
366+
connectionRow(for: connection)
367+
}
328368
}
369+
} header: {
370+
groupHeader(for: group)
329371
}
330-
} header: {
331-
groupHeader(for: group)
332372
}
333373
}
334-
}
335-
.listStyle(.inset)
336-
.scrollContentBackground(.hidden)
337-
.environment(\.defaultMinListRowHeight, 44)
338-
.onKeyPress(.return) {
339-
if let id = selectedConnectionId,
340-
let connection = connections.first(where: { $0.id == id })
341-
{
342-
connectToDatabase(connection)
374+
.listStyle(.inset)
375+
.scrollContentBackground(.hidden)
376+
.focused($focus, equals: .connectionList)
377+
.environment(\.defaultMinListRowHeight, 44)
378+
.onKeyPress(.return) {
379+
if let id = selectedConnectionId,
380+
let connection = connections.first(where: { $0.id == id })
381+
{
382+
connectToDatabase(connection)
383+
}
384+
return .handled
385+
}
386+
.onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in
387+
guard keyPress.modifiers.contains(.control) else { return .ignored }
388+
moveToNextConnection()
389+
scrollToSelection(proxy)
390+
return .handled
391+
}
392+
.onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in
393+
guard keyPress.modifiers.contains(.control) else { return .ignored }
394+
moveToPreviousConnection()
395+
scrollToSelection(proxy)
396+
return .handled
397+
}
398+
.onKeyPress(characters: .init(charactersIn: "h"), phases: .down) { keyPress in
399+
guard keyPress.modifiers.contains(.control) else { return .ignored }
400+
collapseSelectedGroup()
401+
return .handled
402+
}
403+
.onKeyPress(characters: .init(charactersIn: "l"), phases: .down) { keyPress in
404+
guard keyPress.modifiers.contains(.control) else { return .ignored }
405+
expandSelectedGroup()
406+
return .handled
343407
}
344-
return .handled
345-
}
346-
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
347-
guard keyPress.modifiers.contains(.control) else { return .ignored }
348-
moveToNextConnection()
349-
return .handled
350-
}
351-
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
352-
guard keyPress.modifiers.contains(.control) else { return .ignored }
353-
moveToPreviousConnection()
354-
return .handled
355-
}
356-
.onKeyPress(characters: .init(charactersIn: "h"), phases: .down) { keyPress in
357-
guard keyPress.modifiers.contains(.control) else { return .ignored }
358-
collapseSelectedGroup()
359-
return .handled
360-
}
361-
.onKeyPress(characters: .init(charactersIn: "l"), phases: .down) { keyPress in
362-
guard keyPress.modifiers.contains(.control) else { return .ignored }
363-
expandSelectedGroup()
364-
return .handled
365408
}
366409
}
367410

@@ -657,6 +700,12 @@ struct WelcomeWindowView: View {
657700
selectedConnectionId = visible[prev].id
658701
}
659702

703+
private func scrollToSelection(_ proxy: ScrollViewProxy) {
704+
if let id = selectedConnectionId {
705+
proxy.scrollTo(id, anchor: .center)
706+
}
707+
}
708+
660709
private func collapseSelectedGroup() {
661710
guard let id = selectedConnectionId,
662711
let connection = connections.first(where: { $0.id == id }),

TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ struct DatabaseSwitcherSheet: View {
2424
@State private var viewModel: DatabaseSwitcherViewModel
2525
@State private var showCreateDialog = false
2626

27+
private enum FocusField {
28+
case search
29+
case databaseList
30+
}
31+
32+
@FocusState private var focus: FocusField?
33+
2734
private var isSchemaMode: Bool { viewModel.isSchemaMode }
2835

2936
/// The active name used for current-badge comparison, depending on mode.
@@ -106,6 +113,7 @@ struct DatabaseSwitcherSheet: View {
106113
}
107114
.frame(width: 420, height: 480)
108115
.background(Color(nsColor: .windowBackgroundColor))
116+
.defaultFocus($focus, .search)
109117
.task { await viewModel.fetchDatabases() }
110118
.sheet(isPresented: $showCreateDialog) {
111119
CreateDatabaseSheet { name, charset, collation in
@@ -130,12 +138,12 @@ struct DatabaseSwitcherSheet: View {
130138
moveSelection(up: false)
131139
return .handled
132140
}
133-
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
141+
.onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in
134142
guard keyPress.modifiers.contains(.control) else { return .ignored }
135143
moveSelection(up: false)
136144
return .handled
137145
}
138-
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
146+
.onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in
139147
guard keyPress.modifiers.contains(.control) else { return .ignored }
140148
moveSelection(up: true)
141149
return .handled
@@ -158,6 +166,7 @@ struct DatabaseSwitcherSheet: View {
158166
text: $viewModel.searchText)
159167
.textFieldStyle(.plain)
160168
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
169+
.focused($focus, equals: .search)
161170

162171
if !viewModel.searchText.isEmpty {
163172
Button(action: { viewModel.searchText = "" }) {
@@ -235,6 +244,7 @@ struct DatabaseSwitcherSheet: View {
235244
}
236245
.listStyle(.sidebar)
237246
.scrollContentBackground(.hidden)
247+
.focused($focus, equals: .databaseList)
238248
.onChange(of: viewModel.selectedDatabase) { _, newValue in
239249
if let item = newValue {
240250
withAnimation(.easeInOut(duration: 0.15)) {

TablePro/Views/QuickSwitcher/QuickSwitcherView.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ internal struct QuickSwitcherSheet: View {
2222

2323
@State private var viewModel = QuickSwitcherViewModel()
2424

25+
private enum FocusField {
26+
case search
27+
case itemList
28+
}
29+
30+
@FocusState private var focus: FocusField?
31+
2532
var body: some View {
2633
VStack(spacing: 0) {
2734
// Header
@@ -59,6 +66,7 @@ internal struct QuickSwitcherSheet: View {
5966
databaseType: databaseType
6067
)
6168
}
69+
.defaultFocus($focus, .search)
6270
.onExitCommand { dismiss() }
6371
.onKeyPress(.return) {
6472
openSelectedItem()
@@ -72,12 +80,12 @@ internal struct QuickSwitcherSheet: View {
7280
viewModel.moveDown()
7381
return .handled
7482
}
75-
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
83+
.onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in
7684
guard keyPress.modifiers.contains(.control) else { return .ignored }
7785
viewModel.moveDown()
7886
return .handled
7987
}
80-
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
88+
.onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in
8189
guard keyPress.modifiers.contains(.control) else { return .ignored }
8290
viewModel.moveUp()
8391
return .handled
@@ -95,6 +103,7 @@ internal struct QuickSwitcherSheet: View {
95103
TextField("Search tables, views, databases...", text: $viewModel.searchText)
96104
.textFieldStyle(.plain)
97105
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
106+
.focused($focus, equals: .search)
98107

99108
if !viewModel.searchText.isEmpty {
100109
Button(action: { viewModel.searchText = "" }) {
@@ -139,6 +148,7 @@ internal struct QuickSwitcherSheet: View {
139148
}
140149
.listStyle(.sidebar)
141150
.scrollContentBackground(.hidden)
151+
.focused($focus, equals: .itemList)
142152
.onChange(of: viewModel.selectedItemId) { _, newValue in
143153
if let itemId = newValue {
144154
proxy.scrollTo(itemId, anchor: .center)

TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ struct DataChangeManagerExtendedTests {
240240
let state = manager.saveState()
241241
manager.clearChanges()
242242
#expect(!manager.hasChanges)
243-
manager.restoreState(from: state, tableName: "test_table")
243+
manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql)
244244
#expect(manager.hasChanges)
245245
}
246246

@@ -250,7 +250,7 @@ struct DataChangeManagerExtendedTests {
250250
manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"])
251251
let state = manager.saveState()
252252
manager.clearChanges()
253-
manager.restoreState(from: state, tableName: "test_table")
253+
manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql)
254254
#expect(manager.isRowDeleted(2))
255255
}
256256

@@ -263,7 +263,7 @@ struct DataChangeManagerExtendedTests {
263263
)
264264
let state = manager.saveState()
265265
manager.clearChanges()
266-
manager.restoreState(from: state, tableName: "test_table")
266+
manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql)
267267
#expect(manager.isCellModified(rowIndex: 0, columnIndex: 1))
268268
}
269269

@@ -276,7 +276,7 @@ struct DataChangeManagerExtendedTests {
276276
)
277277
let state = manager.saveState()
278278
manager.clearChanges()
279-
manager.restoreState(from: state, tableName: "test_table")
279+
manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql)
280280
manager.recordCellChange(
281281
rowIndex: 0, columnIndex: 2, columnName: "email",
282282
oldValue: "a@test.com", newValue: "b@test.com"
@@ -289,7 +289,7 @@ struct DataChangeManagerExtendedTests {
289289
func emptyStateRoundTrip() {
290290
let manager = makeManager()
291291
let state = manager.saveState()
292-
manager.restoreState(from: state, tableName: "test_table")
292+
manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql)
293293
#expect(!manager.hasChanges)
294294
#expect(manager.changes.isEmpty)
295295
}

TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct SQLStatementGeneratorMSSQLTests {
2323
columns: columns,
2424
primaryKeyColumn: primaryKeyColumn,
2525
databaseType: .mssql,
26-
dialect: PluginManager.shared.sqlDialect(for: .mssql)
26+
dialect: nil
2727
)
2828
}
2929

TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ struct SQLStatementGeneratorNoPKTests {
2424
columns: columns,
2525
primaryKeyColumn: primaryKeyColumn,
2626
databaseType: databaseType,
27-
dialect: PluginManager.shared.sqlDialect(for: databaseType)
27+
dialect: nil
2828
)
2929
}
3030

0 commit comments

Comments
 (0)