From f7c35ad54a310761d957f3a9e1318dd2b7d1965e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 30 May 2026 01:02:38 +0700 Subject: [PATCH 1/2] fix(datagrid): keep keyboard focus across the grid filter flow (#1490) --- CHANGELOG.md | 1 + TablePro/Views/Filter/FilterPanelView.swift | 14 ++++ TablePro/Views/Filter/FilterRowView.swift | 9 ++- .../Views/Filter/FilterValueTextField.swift | 75 +++++++++++++++---- .../Views/Main/Child/MainStatusBarView.swift | 11 ++- .../MainContentCoordinator+GridFocus.swift | 12 +++ .../Views/Results/DataGridCoordinator.swift | 5 ++ 7 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f1aa1e2..6719b7deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Filtering the data grid keeps you on the keyboard. Applying or clearing a filter returns focus to the grid so you can keep moving through cells, Return applies the filter, and Escape closes the filter panel and returns to the grid. (#1490) - Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. - Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483) diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 9719718bc..4677fcf4a 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -40,6 +40,10 @@ struct FilterPanelView: View { } } .background(Color(nsColor: .windowBackgroundColor)) + .focusSection() + .onExitCommand { + closePanelAndFocusGrid() + } .onAppear { if filterState.filters.isEmpty && !columns.isEmpty { coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) @@ -85,6 +89,7 @@ struct FilterPanelView: View { Button("Unset") { coordinator.clearFilterState() onUnset() + coordinator.focusActiveGrid() } .buttonStyle(.bordered) .controlSize(.small) @@ -96,6 +101,7 @@ struct FilterPanelView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) + .keyboardShortcut(.defaultAction) .disabled(validFilterCount == 0) .help(String(localized: "Apply filters")) } @@ -202,9 +208,11 @@ struct FilterPanelView: View { coordinator.removeFilterAndReload(filter) if filterState.filters.isEmpty { coordinator.closeFilterPanel() + coordinator.focusActiveGrid() } }, onSubmit: { applyAllValidFilters() }, + onCancel: { closePanelAndFocusGrid() }, focusedFilterId: $focusedFilterId ) } @@ -237,6 +245,12 @@ struct FilterPanelView: View { private func applyAllValidFilters() { coordinator.applyAllFilters() onApply(coordinator.selectedTabFilterState.appliedFilters) + coordinator.focusActiveGrid() + } + + private func closePanelAndFocusGrid() { + coordinator.closeFilterPanel() + coordinator.focusActiveGrid() } private var isSQLDialect: Bool { diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index cc3fba8f7..e44cd72f1 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -15,6 +15,7 @@ struct FilterRowView: View { let onDuplicate: () -> Void let onRemove: () -> Void let onSubmit: () -> Void + let onCancel: () -> Void @Binding var focusedFilterId: UUID? private var pickerEligibleOperators: Set { @@ -65,6 +66,7 @@ struct FilterRowView: View { .fixedSize() .labelsHidden() .accessibilityLabel(String(localized: "Filter column")) + .accessibilityValue(filter.isRawSQL ? String(localized: "Raw SQL") : filter.columnName) .help(String(localized: "Select filter column")) } @@ -79,6 +81,7 @@ struct FilterRowView: View { .fixedSize() .labelsHidden() .accessibilityLabel(String(localized: "Filter operator")) + .accessibilityValue(filter.filterOperator.displayName) .help(String(localized: "Select filter operator")) } @@ -95,7 +98,8 @@ struct FilterRowView: View { placeholder: "e.g. id = 1", completionSource: rawSQLCompletionSource, allowsMultiLine: true, - onSubmit: onSubmit + onSubmit: onSubmit, + onCancel: onCancel ) .accessibilityLabel(String(localized: "WHERE clause")) } else if filter.filterOperator.requiresValue { @@ -109,7 +113,8 @@ struct FilterRowView: View { identity: filter.id, placeholder: String(localized: "Value"), completionSource: .staticValues(completions), - onSubmit: onSubmit + onSubmit: onSubmit, + onCancel: onCancel ) .frame(minWidth: 80) .accessibilityLabel(String(localized: "Filter value")) diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index b4b0268eb..9ed6527b5 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -20,6 +20,7 @@ struct FilterValueTextField: NSViewRepresentable { var completionSource: FilterCompletionSource = .staticValues([]) var allowsMultiLine: Bool = false var onSubmit: () -> Void = {} + var onCancel: () -> Void = {} static func suggestions(for input: String, in completions: [String]) -> [String] { guard !input.isEmpty else { return [] } @@ -75,12 +76,14 @@ struct FilterValueTextField: NSViewRepresentable { textField.lineBreakMode = .byTruncatingTail } + textField.owner = context.coordinator context.coordinator.textField = textField context.coordinator.text = $text context.coordinator.focusedId = $focusedId context.coordinator.identity = identity context.coordinator.completionSource = completionSource context.coordinator.onSubmit = onSubmit + context.coordinator.onCancel = onCancel return textField } @@ -91,6 +94,7 @@ struct FilterValueTextField: NSViewRepresentable { context.coordinator.identity = identity context.coordinator.completionSource = completionSource context.coordinator.onSubmit = onSubmit + context.coordinator.onCancel = onCancel context.coordinator.textField = textField textField.placeholderString = placeholder @@ -101,18 +105,7 @@ struct FilterValueTextField: NSViewRepresentable { textField.stringValue = text } - if focusedId == identity { - let binding = $focusedId - let pendingId = identity - DispatchQueue.main.async { - guard let window = textField.window, - binding.wrappedValue == pendingId else { return } - if window.firstResponder !== textField.currentEditor() { - window.makeFirstResponder(textField) - } - binding.wrappedValue = nil - } - } + context.coordinator.focusIfRequested() } func makeCoordinator() -> Coordinator { @@ -121,7 +114,8 @@ struct FilterValueTextField: NSViewRepresentable { focusedId: $focusedId, identity: identity, completionSource: completionSource, - onSubmit: onSubmit + onSubmit: onSubmit, + onCancel: onCancel ) } @@ -132,6 +126,7 @@ struct FilterValueTextField: NSViewRepresentable { var identity: UUID var completionSource: FilterCompletionSource var onSubmit: () -> Void + var onCancel: () -> Void weak var textField: NSTextField? private let suggestionState = SuggestionState() @@ -151,13 +146,36 @@ struct FilterValueTextField: NSViewRepresentable { focusedId: Binding, identity: UUID, completionSource: FilterCompletionSource, - onSubmit: @escaping () -> Void + onSubmit: @escaping () -> Void, + onCancel: @escaping () -> Void ) { self.text = text self.focusedId = focusedId self.identity = identity self.completionSource = completionSource self.onSubmit = onSubmit + self.onCancel = onCancel + } + + func focusIfRequested() { + guard focusedId.wrappedValue == identity, + let textField, + let window = textField.window else { return } + if window.firstResponder !== textField.currentEditor() { + window.makeFirstResponder(textField) + } + } + + func handleBecameFirstResponder() { + if focusedId.wrappedValue != identity { + focusedId.wrappedValue = identity + } + } + + func handleResignedFirstResponder() { + if focusedId.wrappedValue == identity { + focusedId.wrappedValue = nil + } } deinit { @@ -195,7 +213,11 @@ struct FilterValueTextField: NSViewRepresentable { return true } if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - dismissSuggestions() + if suggestionPopover != nil { + dismissSuggestions() + return true + } + onCancel() return true } return false @@ -391,6 +413,13 @@ struct FilterValueTextField: NSViewRepresentable { } private final class SubstitutionDisabledTextField: NSTextField { + weak var owner: Coordinator? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + owner?.focusIfRequested() + } + override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result, let editor = currentEditor() as? NSTextView { @@ -399,6 +428,17 @@ struct FilterValueTextField: NSViewRepresentable { editor.isAutomaticTextReplacementEnabled = false editor.isAutomaticSpellingCorrectionEnabled = false } + if result { + owner?.handleBecameFirstResponder() + } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + owner?.handleResignedFirstResponder() + } return result } } @@ -438,6 +478,11 @@ struct FilterValueTextField: NSViewRepresentable { .contentShape(Rectangle()) .onTapGesture { onSelect(item) } .id(index) + .accessibilityElement(children: .ignore) + .accessibilityLabel(item.label) + .accessibilityAddTraits( + state.selectedIndex == index ? [.isButton, .isSelected] : .isButton + ) } } .padding(4) diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 85a86b14b..3f6fc1b25 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -70,6 +70,15 @@ struct MainStatusBarView: View { private var isStructureMode: Bool { viewMode == .structure } private var showsDataChrome: Bool { !isStructureMode } + private var filterToggleHelp: String { + let label = String(localized: "Toggle Filters") + guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: .toggleFilters), + !combo.isCleared else { + return label + } + return "\(label) (\(combo.displayString))" + } + var body: some View { HStack { if snapshot.tabId != nil { @@ -191,7 +200,7 @@ struct MainStatusBarView: View { } .toggleStyle(.button) .controlSize(.small) - .help(String(localized: "Toggle Filters (⇧⌘F)")) + .help(filterToggleHelp) } if snapshot.tabType == .table, snapshot.hasTableName, showsPaginationControls { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift new file mode 100644 index 000000000..71ef1c74e --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift @@ -0,0 +1,12 @@ +// +// MainContentCoordinator+GridFocus.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + func focusActiveGrid() { + dataTabDelegate?.tableViewCoordinator?.focusGrid() + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 73e24aad3..b4ba4493a 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -604,6 +604,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } + func focusGrid() { + guard let tableView, let window = tableView.window else { return } + window.makeFirstResponder(tableView) + } + func beginEditing(displayRow: Int, column: Int) { guard let tableView, let displayCol = DataGridView.tableColumnIndex(for: column, in: tableView, schema: identitySchema) From d6645f6e93f35cfd56ee55749f350ee32350c1e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 30 May 2026 01:09:03 +0700 Subject: [PATCH 2/2] refactor(datagrid): explicit access control on grid focus helpers (#1490) --- .../Main/Extensions/MainContentCoordinator+GridFocus.swift | 2 +- TablePro/Views/Results/DataGridCoordinator.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift index 71ef1c74e..034b724c0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift @@ -5,7 +5,7 @@ import Foundation -extension MainContentCoordinator { +internal extension MainContentCoordinator { func focusActiveGrid() { dataTabDelegate?.tableViewCoordinator?.focusGrid() } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index b4ba4493a..ef781872b 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -604,7 +604,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - func focusGrid() { + internal func focusGrid() { guard let tableView, let window = tableView.window else { return } window.makeFirstResponder(tableView) }