diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf43f655..249218327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Icon-only buttons announced as nothing by VoiceOver across the data grid, row inspector, editor find bar, filter bar, structure, dashboard and settings. +- Foreign key picker rows that could only be chosen with a mouse. +- No spoken sort direction on Query Plan columns. - No columns, indexes or foreign keys listed for a MySQL server that answers `information_schema` with nothing or an error. - Composite foreign key columns listed out of order on MariaDB. - Export dialog listing no tables for a MySQL server behind a proxy, and failing to open at all on Kafka, Cassandra and Teradata. diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift index 96c892236..bcbb62577 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift @@ -41,6 +41,7 @@ struct FindControls: View { .padding(.horizontal, dynamicPadding) } .help("Previous Match") + .accessibilityLabel("Previous Match") .disabled(viewModel.matchesEmpty) Divider() @@ -53,6 +54,7 @@ struct FindControls: View { .padding(.horizontal, dynamicPadding) } .help("Next Match") + .accessibilityLabel("Next Match") .disabled(viewModel.matchesEmpty) } .controlGroupStyle(PanelControlGroupStyle()) @@ -69,6 +71,7 @@ struct FindControls: View { } } .help(condensed ? "Done" : "") + .accessibilityLabel("Done") .padding(.horizontal, dynamicPadding) } .buttonStyle(PanelButtonStyle()) diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift index f5df5b7c5..708c975d5 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift @@ -46,6 +46,7 @@ struct ReplaceControls: View { .opacity(shouldDisableSingle ? 0.33 : 1) } .help(condensed ? "Replace" : "") + .accessibilityLabel("Replace") .disabled(shouldDisableSingle) .frame(maxWidth: .infinity) @@ -64,6 +65,7 @@ struct ReplaceControls: View { .opacity(shouldDisableAll ? 0.33 : 1) } .help(condensed ? "Replace All" : "") + .accessibilityLabel("Replace All") .disabled(shouldDisableAll) .frame(maxWidth: .infinity) } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift b/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift index 7ea2de5e4..3be4b1fb0 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift @@ -114,6 +114,7 @@ struct PanelTextField: View } .buttonStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 20, height: 20))) .opacity(text.isEmpty ? 0 : 1) + .accessibilityLabel("Clear") .disabled(text.isEmpty) } if let trailing = trailingAccessories { diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index a9386ac23..c3867971e 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -409,6 +409,7 @@ struct AIChatPanelView: View { .menuStyle(.button) .buttonStyle(.borderless) .help(String(localized: "Choose AI provider and model")) + .accessibilityLabel(String(localized: "Choose AI provider and model")) } } diff --git a/TablePro/Views/Components/DatabaseEndpointPicker.swift b/TablePro/Views/Components/DatabaseEndpointPicker.swift index aa2946093..f81bc6817 100644 --- a/TablePro/Views/Components/DatabaseEndpointPicker.swift +++ b/TablePro/Views/Components/DatabaseEndpointPicker.swift @@ -284,6 +284,7 @@ internal struct DatabaseEndpointPicker: View { if current?.id == endpoint.id { Image(systemName: "checkmark") .foregroundStyle(.secondary) + .accessibilityLabel(String(localized: "Selected")) } } .contentShape(.rect) diff --git a/TablePro/Views/Editor/QueryParameterPanelView.swift b/TablePro/Views/Editor/QueryParameterPanelView.swift index 6903e8d9b..5922eaeb1 100644 --- a/TablePro/Views/Editor/QueryParameterPanelView.swift +++ b/TablePro/Views/Editor/QueryParameterPanelView.swift @@ -58,6 +58,7 @@ struct QueryParameterPanelView: View { } .buttonStyle(.borderless) .help(String(localized: "Close parameter panel")) + .accessibilityLabel(String(localized: "Close parameter panel")) } .padding(.horizontal, 12) .padding(.vertical, 8) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index da7844b40..939b117c9 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -328,6 +328,7 @@ struct ExportDialog: View { .buttonStyle(.borderless) .fixedSize() .help(String(localized: "Saved selections")) + .accessibilityLabel(String(localized: "Saved selections")) .popover(isPresented: $isNamingProfile, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 10) { Text("Name this selection") diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 599860f11..8457f6321 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -173,6 +173,7 @@ struct FilterPanelView: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) .help(String(localized: "Some columns in this preset don't exist in the current table")) + .accessibilityLabel(String(localized: "Some columns in this preset don't exist in the current table")) } } } diff --git a/TablePro/Views/Filter/NestedFieldPathPicker.swift b/TablePro/Views/Filter/NestedFieldPathPicker.swift index dd3e8e9f6..d18a32fbb 100644 --- a/TablePro/Views/Filter/NestedFieldPathPicker.swift +++ b/TablePro/Views/Filter/NestedFieldPathPicker.swift @@ -125,6 +125,7 @@ struct NestedFieldPathPicker: View { .imageScale(.small) .foregroundStyle(.secondary) .help(String(localized: "Inside an array")) + .accessibilityLabel(String(localized: "Inside an array")) } Text(path.typeName) .font(.caption) diff --git a/TablePro/Views/Inspector/InspectorFilterBar.swift b/TablePro/Views/Inspector/InspectorFilterBar.swift index 45754cc7e..c48598986 100644 --- a/TablePro/Views/Inspector/InspectorFilterBar.swift +++ b/TablePro/Views/Inspector/InspectorFilterBar.swift @@ -113,6 +113,7 @@ struct InspectorFilterBar: View { } } .labelsHidden() + .accessibilityLabel(String(localized: "Filter column")) .frame(maxWidth: 180) .onChange(of: clause.column) { _ in onChange() } @@ -122,6 +123,7 @@ struct InspectorFilterBar: View { } } .labelsHidden() + .accessibilityLabel(String(localized: "Filter operator")) .frame(maxWidth: 160) .onChange(of: clause.op) { _ in onChange() } @@ -145,6 +147,7 @@ struct InspectorFilterBar: View { } .buttonStyle(.plain) .help(String(localized: "Remove filter")) + .accessibilityLabel(String(localized: "Remove filter")) } } diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift index cbdd283ae..97a12177b 100644 --- a/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift @@ -57,6 +57,7 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu // The rebuilt tree is in parse order, so a sort indicator left over from the previous // plan would advertise an order the rows are not in. outlineView?.sortDescriptors = [] + publishSortDirection() outlineView?.reloadData() expandAll() selectRootRow() @@ -134,11 +135,29 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu } func outlineView(_ outlineView: NSOutlineView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + publishSortDirection() guard applyCurrentSort() else { return } outlineView.reloadData() expandAll() } + /// `sortDescriptors` reaches no accessibility client, measured, so the sorted column is only + /// announced when its header cell is told directly. Every column here is click-sortable, so + /// without this a VoiceOver user cannot tell which one is sorted or which way. + internal func publishSortDirection() { + guard let outlineView else { return } + let sorted = outlineView.sortDescriptors.first + for column in outlineView.tableColumns { + let direction: NSAccessibilitySortDirection + if let sorted, sorted.key == column.identifier.rawValue { + direction = sorted.ascending ? .ascending : .descending + } else { + direction = .unknown + } + column.headerCell.setAccessibilitySortDirection(direction) + } + } + /// Reorders the tree by whatever the header currently advertises, keeping the selection. False /// when there is nothing to sort by, which leaves the rows in parse order. @discardableResult diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index d116615ff..489005369 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -226,6 +226,7 @@ struct QueryPlanResultView: View { .buttonStyle(.bordered) .controlSize(.small) .help(String(localized: "Copy EXPLAIN output to clipboard")) + .accessibilityLabel(String(localized: "Copy EXPLAIN output to clipboard")) } .padding(.horizontal, 12) .padding(.vertical, 8) diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index 0e249ec03..26b1a9e42 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -189,6 +189,7 @@ struct ArrayValueEditorView: View { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) .help(Text("This value is not one of the type's current labels")) + .accessibilityLabel(Text("This value is not one of the type's current labels")) } } diff --git a/TablePro/Views/Results/CellImageViewer.swift b/TablePro/Views/Results/CellImageViewer.swift index 34dd84d1b..9e3212b21 100644 --- a/TablePro/Views/Results/CellImageViewer.swift +++ b/TablePro/Views/Results/CellImageViewer.swift @@ -73,6 +73,7 @@ internal struct CellImageViewer: View { } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) + .accessibilityLabel(String(localized: "Open in Window")) } } .padding(.horizontal, 10) diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index ce2a34d44..6b67e94d3 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -148,6 +148,8 @@ struct ForeignKeyPickerView: View { row(for: entry) .contentShape(Rectangle()) .onTapGesture { commit(entry) } + .accessibilityAddTraits(.isButton) + .accessibilityAction { commit(entry) } } .listStyle(.plain) .scrollContentBackground(.hidden) diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index 193842845..699f60bd3 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -87,6 +87,7 @@ internal struct JSONViewerView: View { } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) + .accessibilityLabel(String(localized: "Open in Window")) } } .padding(.horizontal, 10) diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift index 2246ae917..cde345b1e 100644 --- a/TablePro/Views/Results/PhpViewerView.swift +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -84,6 +84,7 @@ internal struct PhpViewerView: View { } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) + .accessibilityLabel(String(localized: "Open in Window")) } } .padding(.horizontal, 10) diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index f2b1c49c1..12193d05f 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -47,6 +47,7 @@ internal struct JsonEditorView: View { } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) + .accessibilityLabel(String(localized: "Open in Window")) } } .padding(4) diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index aa0b9546d..a895de41a 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -55,6 +55,7 @@ internal struct MultiLineEditorView: View { } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) + .accessibilityLabel(String(localized: "Open in Window")) .padding(4) } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 2ee90e8f0..989b06157 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -102,6 +102,7 @@ struct JSONRowInspectorView: View { .menuIndicator(.hidden) .fixedSize() .help(String(localized: "JSON view options")) + .accessibilityLabel(String(localized: "JSON view options")) } // MARK: - Tree diff --git a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift index dab8ddff1..a7eea6629 100644 --- a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift +++ b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift @@ -33,6 +33,7 @@ struct DashboardToolbarView: View { } .buttonStyle(.borderless) .help(viewModel.isPaused ? String(localized: "Resume") : String(localized: "Pause")) + .accessibilityLabel(viewModel.isPaused ? String(localized: "Resume") : String(localized: "Pause")) .disabled(viewModel.refreshInterval == .off) Button { @@ -42,6 +43,7 @@ struct DashboardToolbarView: View { } .buttonStyle(.borderless) .help(String(localized: "Refresh Now")) + .accessibilityLabel(String(localized: "Refresh Now")) .disabled(viewModel.isRefreshing) Spacer() diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 73df79dbf..359949fc8 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -327,6 +327,7 @@ struct AIProviderDetailSheet: View { } .buttonStyle(.borderless) .help(String(localized: "Copy install command")) + .accessibilityLabel(String(localized: "Copy install command")) } label: { Text(CursorAgentCLI.installCommand) .font(.system(.body, design: .monospaced)) diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift index c3263357b..6b2045f66 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -291,6 +291,7 @@ internal struct ThemeEditorColorsSection: View { } .buttonStyle(.borderless) .help(String(localized: "Reset to System Default")) + .accessibilityLabel(String(localized: "Reset to System Default")) } } } diff --git a/TablePro/Views/Settings/LinkedFoldersSection.swift b/TablePro/Views/Settings/LinkedFoldersSection.swift index 68db362b8..08db78ede 100644 --- a/TablePro/Views/Settings/LinkedFoldersSection.swift +++ b/TablePro/Views/Settings/LinkedFoldersSection.swift @@ -73,6 +73,7 @@ struct LinkedFoldersSection: View { .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() + .accessibilityLabel(folder.name) VStack(alignment: .leading, spacing: 1) { Text(folder.name) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index d7437e2e4..145699d51 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -223,6 +223,7 @@ struct CreateTableView: View { .frame(width: 24, height: 24) } .help(String(localized: "Add Row")) + .accessibilityLabel(String(localized: "Add Row")) .disabled(!isGridTab) Button(action: { gridDelegate.dataGridDeleteRows(selectedRows) }) { @@ -230,6 +231,7 @@ struct CreateTableView: View { .frame(width: 24, height: 24) } .help(String(localized: "Delete Selected")) + .accessibilityLabel(String(localized: "Delete Selected")) .disabled(!isGridTab || selectedRows.isEmpty) issueMessage(issues) diff --git a/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift index b734c98fb..0bd5215c7 100644 --- a/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift +++ b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift @@ -73,6 +73,7 @@ struct PrivilegeChecklistView: View { .fixedSize() .disabled(viewModel.privilegeSections.isEmpty) .help(String(localized: "Bulk actions")) + .accessibilityLabel(String(localized: "Bulk actions")) } // MARK: - Content diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift b/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift index 420f93b73..a5f4d336c 100644 --- a/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift +++ b/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift @@ -71,6 +71,7 @@ struct ScopeSummaryView: View { Image(systemName: "arrow.up.forward.square") .foregroundStyle(.secondary) .help(String(localized: "Can grant these privileges to others.")) + .accessibilityLabel(String(localized: "Can grant these privileges to others.")) } } diff --git a/TableProTests/Accessibility/AccessibleControlNameTests.swift b/TableProTests/Accessibility/AccessibleControlNameTests.swift new file mode 100644 index 000000000..b71fd4d8b --- /dev/null +++ b/TableProTests/Accessibility/AccessibleControlNameTests.swift @@ -0,0 +1,146 @@ +// +// AccessibleControlNameTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +/// A control with no accessible name is silent to VoiceOver, and an icon-only button is the shape +/// that gets there by accident: it looks finished on screen because the symbol reads as a label to +/// a sighted user. +/// +/// `.help()` is not a substitute, and treating it as one is what this suite exists to catch. On +/// macOS it sets the tooltip and the accessibility *hint*; VoiceOver announces the label first and +/// reads hints only after a delay, if the user has not turned them off. So a button carrying a +/// perfectly good localized string in `.help()` and nothing in `.accessibilityLabel()` announces +/// its SF Symbol name, or nothing at all. +@Suite("Accessible control names") +struct AccessibleControlNameTests { + /// Every one of these had a name in `.help()` and none in `.accessibilityLabel()`. The pairs + /// are (file, the label string that must appear in it), so the test fails if a label is dropped + /// rather than merely if some label exists. + private static let labelledControls: [(path: String, label: String)] = [ + ("TablePro/Views/Results/CellImageViewer.swift", "Open in Window"), + ("TablePro/Views/Results/JSONViewerView.swift", "Open in Window"), + ("TablePro/Views/Results/PhpViewerView.swift", "Open in Window"), + ("TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift", "Open in Window"), + ("TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift", "Open in Window"), + ("TablePro/Views/Editor/QueryParameterPanelView.swift", "Close parameter panel"), + ("TablePro/Views/Inspector/InspectorFilterBar.swift", "Remove filter"), + ("TablePro/Views/Inspector/InspectorFilterBar.swift", "Filter column"), + ("TablePro/Views/Inspector/InspectorFilterBar.swift", "Filter operator"), + ("TablePro/Views/Structure/CreateTableView.swift", "Add Row"), + ("TablePro/Views/Structure/CreateTableView.swift", "Delete Selected"), + ("TablePro/Views/ServerDashboard/DashboardToolbarView.swift", "Refresh Now"), + ("TablePro/Views/Settings/AIProviderDetailSheet.swift", "Copy install command"), + ("TablePro/Views/Settings/LinkedFoldersSection.swift", "folder.name"), + ("TablePro/Views/UsersRoles/PrivilegeChecklistView.swift", "Bulk actions"), + ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift", "Previous Match"), + ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift", "Next Match"), + ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift", "Replace All"), + ("Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift", "Clear") + ] + + @Test("Every icon-only control that had only a tooltip now carries an accessibility label") + func iconOnlyControlsCarryLabels() throws { + let root = try repositoryRoot() + for control in Self.labelledControls { + let source = try String(contentsOf: root.appendingPathComponent(control.path), encoding: .utf8) + #expect( + source.contains(".accessibilityLabel") && source.contains(control.label), + "\(control.path) lost the accessibility label for \(control.label)" + ) + } + } + + /// The find bar's clear button is the one that had neither a label nor a tooltip, so nothing + /// named it at all. + @Test("The panel text field's clear button is named") + func clearButtonIsNamed() throws { + let root = try repositoryRoot() + let path = "Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift" + let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + + #expect(source.contains(#".accessibilityLabel("Clear")"#)) + } + + /// Committing a foreign-key row used to happen only in `onTapGesture`, which no assistive + /// technology can reach, so the picker's whole purpose was mouse-only. + @Test("A foreign-key row can be committed without a mouse") + func foreignKeyRowHasAPressAction() throws { + let root = try repositoryRoot() + let path = "TablePro/Views/Results/ForeignKeyPickerView.swift" + let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + + #expect(source.contains(".accessibilityAction { commit(entry) }")) + #expect(source.contains(".accessibilityAddTraits(.isButton)")) + } + + private func repositoryRoot(file: StaticString = #filePath) throws -> URL { + var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + while directory.path != "/" { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw AccessibilityTestError.repositoryRootNotFound + } + + private enum AccessibilityTestError: Error { + case repositoryRootNotFound + } +} + +/// `NSTableView.sortDescriptors` paints the header's arrow but reaches no accessibility client, +/// measured, so a sorted column is announced only when its header cell is told directly. The plan +/// outline gives every column a `sortDescriptorPrototype`, so all of them are click-sortable, and +/// none of them said which way it was sorted. +@Suite("Query plan sort direction") +@MainActor +struct QueryPlanSortDirectionTests { + @Test("The sorted column publishes its direction and the others publish none") + func sortedColumnPublishesItsDirection() { + let outlineView = NSOutlineView() + let coordinator = QueryPlanOutlineCoordinator() + coordinator.outlineView = outlineView + coordinator.configureColumns(on: outlineView) + + let sortedKey = try? #require(outlineView.tableColumns.first?.identifier.rawValue) + let key = sortedKey ?? "" + outlineView.sortDescriptors = [NSSortDescriptor(key: key, ascending: false)] + coordinator.publishSortDirection() + + for column in outlineView.tableColumns { + let published = column.headerCell.accessibilitySortDirection() + if column.identifier.rawValue == key { + #expect(published == .descending, "the sorted column published \(published.rawValue)") + } else { + #expect(published == .unknown, "\(column.identifier.rawValue) published \(published.rawValue)") + } + } + } + + @Test("Clearing the sort clears every column's direction") + func clearingTheSortClearsEveryDirection() { + let outlineView = NSOutlineView() + let coordinator = QueryPlanOutlineCoordinator() + coordinator.outlineView = outlineView + coordinator.configureColumns(on: outlineView) + + guard let first = outlineView.tableColumns.first else { return } + outlineView.sortDescriptors = [NSSortDescriptor(key: first.identifier.rawValue, ascending: true)] + coordinator.publishSortDirection() + #expect(first.headerCell.accessibilitySortDirection() == .ascending) + + outlineView.sortDescriptors = [] + coordinator.publishSortDirection() + + for column in outlineView.tableColumns { + #expect(column.headerCell.accessibilitySortDirection() == .unknown) + } + } +}