Skip to content

Commit 2559641

Browse files
authored
fix(datagrid): keep keyboard focus across the grid filter flow (#1490) (#1492)
* fix(datagrid): keep keyboard focus across the grid filter flow (#1490) * refactor(datagrid): explicit access control on grid focus helpers (#1490)
1 parent fd7677a commit 2559641

7 files changed

Lines changed: 109 additions & 18 deletions

File tree

CHANGELOG.md

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

3030
### Fixed
3131

32+
- 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)
3233
- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
3334
- 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)
3435

TablePro/Views/Filter/FilterPanelView.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ struct FilterPanelView: View {
4040
}
4141
}
4242
.background(Color(nsColor: .windowBackgroundColor))
43+
.focusSection()
44+
.onExitCommand {
45+
closePanelAndFocusGrid()
46+
}
4347
.onAppear {
4448
if filterState.filters.isEmpty && !columns.isEmpty {
4549
coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn)
@@ -85,6 +89,7 @@ struct FilterPanelView: View {
8589
Button("Unset") {
8690
coordinator.clearFilterState()
8791
onUnset()
92+
coordinator.focusActiveGrid()
8893
}
8994
.buttonStyle(.bordered)
9095
.controlSize(.small)
@@ -96,6 +101,7 @@ struct FilterPanelView: View {
96101
}
97102
.buttonStyle(.borderedProminent)
98103
.controlSize(.small)
104+
.keyboardShortcut(.defaultAction)
99105
.disabled(validFilterCount == 0)
100106
.help(String(localized: "Apply filters"))
101107
}
@@ -202,9 +208,11 @@ struct FilterPanelView: View {
202208
coordinator.removeFilterAndReload(filter)
203209
if filterState.filters.isEmpty {
204210
coordinator.closeFilterPanel()
211+
coordinator.focusActiveGrid()
205212
}
206213
},
207214
onSubmit: { applyAllValidFilters() },
215+
onCancel: { closePanelAndFocusGrid() },
208216
focusedFilterId: $focusedFilterId
209217
)
210218
}
@@ -237,6 +245,12 @@ struct FilterPanelView: View {
237245
private func applyAllValidFilters() {
238246
coordinator.applyAllFilters()
239247
onApply(coordinator.selectedTabFilterState.appliedFilters)
248+
coordinator.focusActiveGrid()
249+
}
250+
251+
private func closePanelAndFocusGrid() {
252+
coordinator.closeFilterPanel()
253+
coordinator.focusActiveGrid()
240254
}
241255

242256
private var isSQLDialect: Bool {

TablePro/Views/Filter/FilterRowView.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ struct FilterRowView: View {
1515
let onDuplicate: () -> Void
1616
let onRemove: () -> Void
1717
let onSubmit: () -> Void
18+
let onCancel: () -> Void
1819
@Binding var focusedFilterId: UUID?
1920

2021
private var pickerEligibleOperators: Set<FilterOperator> {
@@ -65,6 +66,7 @@ struct FilterRowView: View {
6566
.fixedSize()
6667
.labelsHidden()
6768
.accessibilityLabel(String(localized: "Filter column"))
69+
.accessibilityValue(filter.isRawSQL ? String(localized: "Raw SQL") : filter.columnName)
6870
.help(String(localized: "Select filter column"))
6971
}
7072

@@ -79,6 +81,7 @@ struct FilterRowView: View {
7981
.fixedSize()
8082
.labelsHidden()
8183
.accessibilityLabel(String(localized: "Filter operator"))
84+
.accessibilityValue(filter.filterOperator.displayName)
8285
.help(String(localized: "Select filter operator"))
8386
}
8487

@@ -95,7 +98,8 @@ struct FilterRowView: View {
9598
placeholder: "e.g. id = 1",
9699
completionSource: rawSQLCompletionSource,
97100
allowsMultiLine: true,
98-
onSubmit: onSubmit
101+
onSubmit: onSubmit,
102+
onCancel: onCancel
99103
)
100104
.accessibilityLabel(String(localized: "WHERE clause"))
101105
} else if filter.filterOperator.requiresValue {
@@ -109,7 +113,8 @@ struct FilterRowView: View {
109113
identity: filter.id,
110114
placeholder: String(localized: "Value"),
111115
completionSource: .staticValues(completions),
112-
onSubmit: onSubmit
116+
onSubmit: onSubmit,
117+
onCancel: onCancel
113118
)
114119
.frame(minWidth: 80)
115120
.accessibilityLabel(String(localized: "Filter value"))

TablePro/Views/Filter/FilterValueTextField.swift

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct FilterValueTextField: NSViewRepresentable {
2020
var completionSource: FilterCompletionSource = .staticValues([])
2121
var allowsMultiLine: Bool = false
2222
var onSubmit: () -> Void = {}
23+
var onCancel: () -> Void = {}
2324

2425
static func suggestions(for input: String, in completions: [String]) -> [String] {
2526
guard !input.isEmpty else { return [] }
@@ -75,12 +76,14 @@ struct FilterValueTextField: NSViewRepresentable {
7576
textField.lineBreakMode = .byTruncatingTail
7677
}
7778

79+
textField.owner = context.coordinator
7880
context.coordinator.textField = textField
7981
context.coordinator.text = $text
8082
context.coordinator.focusedId = $focusedId
8183
context.coordinator.identity = identity
8284
context.coordinator.completionSource = completionSource
8385
context.coordinator.onSubmit = onSubmit
86+
context.coordinator.onCancel = onCancel
8487

8588
return textField
8689
}
@@ -91,6 +94,7 @@ struct FilterValueTextField: NSViewRepresentable {
9194
context.coordinator.identity = identity
9295
context.coordinator.completionSource = completionSource
9396
context.coordinator.onSubmit = onSubmit
97+
context.coordinator.onCancel = onCancel
9498
context.coordinator.textField = textField
9599

96100
textField.placeholderString = placeholder
@@ -101,18 +105,7 @@ struct FilterValueTextField: NSViewRepresentable {
101105
textField.stringValue = text
102106
}
103107

104-
if focusedId == identity {
105-
let binding = $focusedId
106-
let pendingId = identity
107-
DispatchQueue.main.async {
108-
guard let window = textField.window,
109-
binding.wrappedValue == pendingId else { return }
110-
if window.firstResponder !== textField.currentEditor() {
111-
window.makeFirstResponder(textField)
112-
}
113-
binding.wrappedValue = nil
114-
}
115-
}
108+
context.coordinator.focusIfRequested()
116109
}
117110

118111
func makeCoordinator() -> Coordinator {
@@ -121,7 +114,8 @@ struct FilterValueTextField: NSViewRepresentable {
121114
focusedId: $focusedId,
122115
identity: identity,
123116
completionSource: completionSource,
124-
onSubmit: onSubmit
117+
onSubmit: onSubmit,
118+
onCancel: onCancel
125119
)
126120
}
127121

@@ -132,6 +126,7 @@ struct FilterValueTextField: NSViewRepresentable {
132126
var identity: UUID
133127
var completionSource: FilterCompletionSource
134128
var onSubmit: () -> Void
129+
var onCancel: () -> Void
135130
weak var textField: NSTextField?
136131

137132
private let suggestionState = SuggestionState()
@@ -151,13 +146,36 @@ struct FilterValueTextField: NSViewRepresentable {
151146
focusedId: Binding<UUID?>,
152147
identity: UUID,
153148
completionSource: FilterCompletionSource,
154-
onSubmit: @escaping () -> Void
149+
onSubmit: @escaping () -> Void,
150+
onCancel: @escaping () -> Void
155151
) {
156152
self.text = text
157153
self.focusedId = focusedId
158154
self.identity = identity
159155
self.completionSource = completionSource
160156
self.onSubmit = onSubmit
157+
self.onCancel = onCancel
158+
}
159+
160+
func focusIfRequested() {
161+
guard focusedId.wrappedValue == identity,
162+
let textField,
163+
let window = textField.window else { return }
164+
if window.firstResponder !== textField.currentEditor() {
165+
window.makeFirstResponder(textField)
166+
}
167+
}
168+
169+
func handleBecameFirstResponder() {
170+
if focusedId.wrappedValue != identity {
171+
focusedId.wrappedValue = identity
172+
}
173+
}
174+
175+
func handleResignedFirstResponder() {
176+
if focusedId.wrappedValue == identity {
177+
focusedId.wrappedValue = nil
178+
}
161179
}
162180

163181
deinit {
@@ -195,7 +213,11 @@ struct FilterValueTextField: NSViewRepresentable {
195213
return true
196214
}
197215
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
198-
dismissSuggestions()
216+
if suggestionPopover != nil {
217+
dismissSuggestions()
218+
return true
219+
}
220+
onCancel()
199221
return true
200222
}
201223
return false
@@ -391,6 +413,13 @@ struct FilterValueTextField: NSViewRepresentable {
391413
}
392414

393415
private final class SubstitutionDisabledTextField: NSTextField {
416+
weak var owner: Coordinator?
417+
418+
override func viewDidMoveToWindow() {
419+
super.viewDidMoveToWindow()
420+
owner?.focusIfRequested()
421+
}
422+
394423
override func becomeFirstResponder() -> Bool {
395424
let result = super.becomeFirstResponder()
396425
if result, let editor = currentEditor() as? NSTextView {
@@ -399,6 +428,17 @@ struct FilterValueTextField: NSViewRepresentable {
399428
editor.isAutomaticTextReplacementEnabled = false
400429
editor.isAutomaticSpellingCorrectionEnabled = false
401430
}
431+
if result {
432+
owner?.handleBecameFirstResponder()
433+
}
434+
return result
435+
}
436+
437+
override func resignFirstResponder() -> Bool {
438+
let result = super.resignFirstResponder()
439+
if result {
440+
owner?.handleResignedFirstResponder()
441+
}
402442
return result
403443
}
404444
}
@@ -438,6 +478,11 @@ struct FilterValueTextField: NSViewRepresentable {
438478
.contentShape(Rectangle())
439479
.onTapGesture { onSelect(item) }
440480
.id(index)
481+
.accessibilityElement(children: .ignore)
482+
.accessibilityLabel(item.label)
483+
.accessibilityAddTraits(
484+
state.selectedIndex == index ? [.isButton, .isSelected] : .isButton
485+
)
441486
}
442487
}
443488
.padding(4)

TablePro/Views/Main/Child/MainStatusBarView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ struct MainStatusBarView: View {
7070
private var isStructureMode: Bool { viewMode == .structure }
7171
private var showsDataChrome: Bool { !isStructureMode }
7272

73+
private var filterToggleHelp: String {
74+
let label = String(localized: "Toggle Filters")
75+
guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: .toggleFilters),
76+
!combo.isCleared else {
77+
return label
78+
}
79+
return "\(label) (\(combo.displayString))"
80+
}
81+
7382
var body: some View {
7483
HStack {
7584
if snapshot.tabId != nil {
@@ -191,7 +200,7 @@ struct MainStatusBarView: View {
191200
}
192201
.toggleStyle(.button)
193202
.controlSize(.small)
194-
.help(String(localized: "Toggle Filters (⇧⌘F)"))
203+
.help(filterToggleHelp)
195204
}
196205

197206
if snapshot.tabType == .table, snapshot.hasTableName, showsPaginationControls {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// MainContentCoordinator+GridFocus.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal extension MainContentCoordinator {
9+
func focusActiveGrid() {
10+
dataTabDelegate?.tableViewCoordinator?.focusGrid()
11+
}
12+
}

TablePro/Views/Results/DataGridCoordinator.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
604604
}
605605
}
606606

607+
internal func focusGrid() {
608+
guard let tableView, let window = tableView.window else { return }
609+
window.makeFirstResponder(tableView)
610+
}
611+
607612
func beginEditing(displayRow: Int, column: Int) {
608613
guard let tableView,
609614
let displayCol = DataGridView.tableColumnIndex(for: column, in: tableView, schema: identitySchema)

0 commit comments

Comments
 (0)