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..034b724c0 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift @@ -0,0 +1,12 @@ +// +// MainContentCoordinator+GridFocus.swift +// TablePro +// + +import Foundation + +internal extension MainContentCoordinator { + func focusActiveGrid() { + dataTabDelegate?.tableViewCoordinator?.focusGrid() + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 73e24aad3..ef781872b 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -604,6 +604,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } + internal 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)