diff --git a/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift b/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift index 12e4f66587abe..2440aa55f7e74 100644 --- a/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift +++ b/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift @@ -163,6 +163,24 @@ struct TooltipPresenter: ViewModifier { !bubbleFrame.contains(point) && !(isTap && viewFrame.contains(point)) } + /// Whether focus holds the bubble up: only focus the keyboard brought, so a + /// bubble a pointer opened on a view a click focused hides when the pointer + /// leaves, as core's does. + nonisolated static func holdsByFocus(isFocused: Bool, focusIsKeyboard: Bool) -> Bool { + isFocused && focusIsKeyboard + } + + /// Whether the bubble should be up now, as `update()` decides it: words, + /// and the pointer on the view or the bubble, or focus the keyboard brought. + nonisolated static func showsBubble( + hasText: Bool, isHovering: Bool, isHoveringBubble: Bool, isFocused: Bool, focusIsKeyboard: Bool, + focus: TooltipFocus + ) -> Bool { + wantsBubble( + hasText: hasText, isHovering: isHovering, isHoveringBubble: isHoveringBubble, + isFocused: holdsByFocus(isFocused: isFocused, focusIsKeyboard: focusIsKeyboard), focus: focus) + } + /// Whether focus came from the keyboard, as core's `:focus-visible`: not /// while a pointer rests on the view, and not within a press of it. nonisolated static func isKeyboardFocus(isHovering: Bool, sincePress: TimeInterval) -> Bool { @@ -247,10 +265,10 @@ struct TooltipPresenter: ViewModifier { /// Shows the bubble after the delay; hides it after the bridge. private func update() { pending?.cancel() - let keyboardFocused = isFocused && focusIsKeyboard - let wants = Self.wantsBubble( - hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble, - isFocused: keyboardFocused || (isShown && isFocused), focus: focus) + let keyboardFocused = Self.holdsByFocus(isFocused: isFocused, focusIsKeyboard: focusIsKeyboard) + let wants = Self.showsBubble( + hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble, isFocused: isFocused, + focusIsKeyboard: focusIsKeyboard, focus: focus) guard wants != isShown else { return } let byFocus = keyboardFocused && focus != .none && !isHovering && !isHoveringBubble let wait = Self.wait(showing: wants, byFocus: byFocus) diff --git a/packages/native/apple/Tests/AstryxTests/TooltipPresenterTests.swift b/packages/native/apple/Tests/AstryxTests/TooltipPresenterTests.swift index b06257f6f2426..5fb0994349e9a 100644 --- a/packages/native/apple/Tests/AstryxTests/TooltipPresenterTests.swift +++ b/packages/native/apple/Tests/AstryxTests/TooltipPresenterTests.swift @@ -158,3 +158,13 @@ import Testing #expect(P.slide(viewMinX: 760, viewWidth: 40, bubbleWidth: 100, windowWidth: 800, margin: 60, trailing: 16) == -46) } +@Test func aClickedViewsBubbleHidesWhenThePointerLeaves() { + typealias P = TooltipPresenter + // Hovered: up, whatever brought focus. + #expect(P.showsBubble(hasText: true, isHovering: true, isHoveringBubble: false, isFocused: true, focusIsKeyboard: false, focus: .tabStop)) + // The pointer leaves a view a click focused: down. + #expect(!P.showsBubble(hasText: true, isHovering: false, isHoveringBubble: false, isFocused: true, focusIsKeyboard: false, focus: .tabStop)) + // Keyboard focus holds it. + #expect(P.showsBubble(hasText: true, isHovering: false, isHoveringBubble: false, isFocused: true, focusIsKeyboard: true, focus: .tabStop)) +} + diff --git a/packages/native/specs/swift/library/tooltip.spec.md b/packages/native/specs/swift/library/tooltip.spec.md index b3ffc9591d4c1..600802fe5a0b4 100644 --- a/packages/native/specs/swift/library/tooltip.spec.md +++ b/packages/native/specs/swift/library/tooltip.spec.md @@ -69,6 +69,7 @@ shows with the system's own look and delay, never on keyboard focus or a tap. 300-point width. The bubble draws in the view's own layer, so a scroll view or another clipping container around the view cuts it off, where core's top layer is never clipped. + On its first show, before the window's safe area and the bubble's own size are known, the bubble opens above the view, or below it when even the gap does not fit under the `space.s4` floor, with no slide, and moves into place on the next frame. - **INV2 — Pointer.** A pointer resting on the view shows the bubble after core's 200 ms. It stays while the pointer is on the view or on the bubble, hides 100 ms after the pointer leaves both, and a press hides it. @@ -79,6 +80,8 @@ shows with the system's own look and delay, never on keyboard focus or a tap. that arrives while a pointer rests on the view, or within half a second of a press, shows nothing until focus leaves and returns. SwiftUI does not say who moved focus, so focus code moves counts as the keyboard's. + A bubble a pointer opened on a view a click focused hides when the pointer + leaves; only keyboard focus holds it. - **INV4 — Escape.** Escape hides a shown bubble wherever focus is and goes no further, as core's top-most layer takes the press. - **INV5 — Touch.** `auto`, core's default, opens the tooltip on a tap unless @@ -117,14 +120,14 @@ tooltip tests prove it stays current. ## Verification -| Invariant | Evidence | Failure signal | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| INV1 | `TooltipPresenterTests.swift`: above unless no room; slides inside the window; long words wrap within 300 points | a bubble below with room above, or one line past 300 points | -| INV2 | `TooltipPresenterTests.swift`: delay and bridge; the pointer on the view or the bubble keeps it up | a bubble that hides while hovered or shows at once on a pointer | -| INV3 | `TooltipPresenterTests.swift`: focus shows at once for `control` and `tabStop`, never for `none`; only keyboard focus | a delayed focus bubble, one on a view focus does not reach, or one a pointer's focus opens | -| INV5 | `TooltipPresenterTests.swift`: `auto` resolves as core's; only a direct touch on a tap trigger opens it; a gesture elsewhere closes it, one on the view or the bubble does not | a bubble from a pointer click, under VoiceOver, or on a control under `auto`; one a gesture elsewhere leaves open | -| INV6 | `TooltipPresenterTests.swift`: blank words and a disabled view show nothing | a tooltip on a disabled view | -| INV8 | `TooltipPresenterTests.swift`: the bubble's colors and padding in two themes | a bubble in other colors | -| INV9 | `TooltipPresenterTests.swift`: a view with a tooltip draws as it did without | a decorated view that draws differently | -| No `.help` | `TooltipPresenterTests.swift`: no Swift source uses `.help(` | a view that shows tooltip words through `.help` | -| INV2–INV7 | Not measured: hover, focus, Escape, and tap need an app-hosted UI test, which the package lacks | none until that test exists | +| Invariant | Evidence | Failure signal | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| INV1 | `TooltipPresenterTests.swift`: above unless no room; slides inside the window; long words wrap within 300 points | a bubble below with room above, or one line past 300 points | +| INV2 | `TooltipPresenterTests.swift`: delay and bridge; the pointer on the view or the bubble keeps it up | a bubble that hides while hovered or shows at once on a pointer | +| INV3 | `TooltipPresenterTests.swift`: focus shows at once for `control` and `tabStop`, never for `none`; only keyboard focus opens or holds it | a delayed focus bubble, one on a view focus does not reach, or one a click's focus opens or holds | +| INV5 | `TooltipPresenterTests.swift`: `auto` resolves as core's; only a direct touch on a tap trigger opens it; a tap elsewhere closes it, a tap on the view toggles it, a scroll or pinch on the view closes it, and a gesture on the bubble does not | a bubble from a pointer click, under VoiceOver, or on a control under `auto`; one a gesture elsewhere or a scroll on the view leaves open | +| INV6 | `TooltipPresenterTests.swift`: blank words and a disabled view show nothing | a tooltip on a disabled view | +| INV8 | `TooltipPresenterTests.swift`: the bubble's colors and padding in two themes | a bubble in other colors | +| INV9 | `TooltipPresenterTests.swift`: a view with a tooltip draws as it did without | a decorated view that draws differently | +| No `.help` | `TooltipPresenterTests.swift`: no Swift source uses `.help(` | a view that shows tooltip words through `.help` | +| INV2–INV7 | Not measured: hover, focus, Escape, and tap need an app-hosted UI test, which the package lacks | none until that test exists |