From 6607a9b9fe0f5844493e87d3e9d76ea5b4b14464 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 29 Aug 2026 22:42:24 +0700 Subject: [PATCH] fix(tabs): keep the editor tabs reachable to accessibility over the pointer's owner Claude-Session: https://claude.ai/code/session_01L7uaHbJBPV1LaWL5QXzxyp --- .../Views/Main/EditorTabInteractionView.swift | 16 ++++++++++++++++ TableProUITests/EditorTabDetachUITests.swift | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/EditorTabInteractionView.swift b/TablePro/Views/Main/EditorTabInteractionView.swift index 1a19387bd..ee3a2f173 100644 --- a/TablePro/Views/Main/EditorTabInteractionView.swift +++ b/TablePro/Views/Main/EditorTabInteractionView.swift @@ -31,6 +31,7 @@ internal final class EditorTabInteractionView: NSView { /// the content. internal var onRowCountChanged: ((Int) -> Void)? + private var isResolvingAccessibilityHit = false private var hoverTrackingArea: NSTrackingArea? private var lastActivatedTabId: UUID? @@ -110,12 +111,27 @@ internal final class EditorTabInteractionView: NSView { /// Claims the track and nothing else, so a press on a tab is this view's and a press on the /// new-tab button, on the band's insets or on the chrome below the track is not. + /// + /// The claim is for the pointer alone. Accessibility resolves a screen point through this same + /// method, so claiming it unconditionally answered "the strip" for every tab and took all of + /// them out of reach of VoiceOver, Switch Control, Voice Control and XCUITest at once: this + /// view publishes nothing, so there was no element under a tab to speak, press or click. That + /// shipped in #2571 and turned the whole UI suite red from the commit that merged it. override internal func hitTest(_ point: NSPoint) -> NSView? { + guard !isResolvingAccessibilityHit else { return super.hitTest(point) } let local = convert(point, from: superview) guard trackRect.contains(local) else { return super.hitTest(point) } return self } + /// Answers from the SwiftUI tree, which is where the tabs publish themselves. The pointer's + /// claim is lifted for the length of the question. + override internal func accessibilityHitTest(_ point: NSPoint) -> Any? { + isResolvingAccessibilityHit = true + defer { isResolvingAccessibilityHit = false } + return super.accessibilityHitTest(point) + } + private func tabIndex(atViewPoint point: CGPoint) -> Int? { EditorTabRunLayoutBuilder.index(at: contentPoint(fromViewPoint: point), in: interaction.run) } diff --git a/TableProUITests/EditorTabDetachUITests.swift b/TableProUITests/EditorTabDetachUITests.swift index db6c4e32a..075fce6fb 100644 --- a/TableProUITests/EditorTabDetachUITests.swift +++ b/TableProUITests/EditorTabDetachUITests.swift @@ -80,7 +80,7 @@ final class EditorTabDetachUITests: UITestCase { let target = try XCTUnwrap(tabLabels(in: window).last) tab(named: target, in: window).rightClick() - let item = app.menuItems["Move Tab to New Window"] + let item = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch XCTAssertTrue(item.waitToExist(timeout: 5), "The command must be listed") XCTAssertTrue(item.isEnabled, "It must be offered on an idle tab among others") app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) @@ -92,7 +92,7 @@ final class EditorTabDetachUITests: UITestCase { let target = tab(named: name, in: window) XCTAssertTrue(waitUntilHittable(target, timeout: 20), "The tab must be hittable") target.rightClick() - let item = app.menuItems["Move Tab to New Window"] + let item = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch XCTAssertTrue(item.waitToExist(timeout: 5), "The command must be listed") item.click() }