From f1b29c17bf37e472da8e2cc38dcd5e26dce2f4f3 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 6 Feb 2026 19:17:46 +0100 Subject: [PATCH] Feat: RadioButtonGroup wrapping navigation for seamless cycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior: - Vertical groups: Up/Down wrap around (down at end → first, up at start → last) - Horizontal groups: Left/Right wrap around (right at end → first, left at start → last) - Always return true (event consumed) — never falls through to FocusManager - Enables smooth cycling through options without leaving the group Implementation: - Modified handleKeyEvent to use ternary wrap logic - All navigation keys now always consumed (return true) - Tab still exits group to next focusable element Testing: - Updated 'boundaryNavigation' test to verify wrapping behavior - Test now checks that Up at start wraps to last item - All 591 tests passing User Experience: - Stay in group when cycling options with arrow keys - Tab exits to next group (standard radio group UX) --- Sources/TUIkit/Views/RadioButton.swift | 40 ++++++++++-------------- Tests/TUIkitTests/RadioButtonTests.swift | 9 +++--- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/Sources/TUIkit/Views/RadioButton.swift b/Sources/TUIkit/Views/RadioButton.swift index 01b4962..58e793c 100644 --- a/Sources/TUIkit/Views/RadioButton.swift +++ b/Sources/TUIkit/Views/RadioButton.swift @@ -203,42 +203,34 @@ extension RadioButtonGroupHandler { case .up: // Up/Down only work for vertical orientation guard orientation == .vertical else { return false } - if focusedIndex > 0 { - focusedIndex -= 1 - selection.wrappedValue = itemValues[focusedIndex] - return true - } - return false + // Wrap around: go to last item if at beginning + focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 + selection.wrappedValue = itemValues[focusedIndex] + return true case .down: // Up/Down only work for vertical orientation guard orientation == .vertical else { return false } - if focusedIndex < itemValues.count - 1 { - focusedIndex += 1 - selection.wrappedValue = itemValues[focusedIndex] - return true - } - return false + // Wrap around: go to first item if at end + focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 + selection.wrappedValue = itemValues[focusedIndex] + return true case .left: // Left/Right only work for horizontal orientation guard orientation == .horizontal else { return false } - if focusedIndex > 0 { - focusedIndex -= 1 - selection.wrappedValue = itemValues[focusedIndex] - return true - } - return false + // Wrap around: go to last item if at beginning + focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 + selection.wrappedValue = itemValues[focusedIndex] + return true case .right: // Left/Right only work for horizontal orientation guard orientation == .horizontal else { return false } - if focusedIndex < itemValues.count - 1 { - focusedIndex += 1 - selection.wrappedValue = itemValues[focusedIndex] - return true - } - return false + // Wrap around: go to first item if at end + focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 + selection.wrappedValue = itemValues[focusedIndex] + return true case .enter, .character(" "): // Select current focused item diff --git a/Tests/TUIkitTests/RadioButtonTests.swift b/Tests/TUIkitTests/RadioButtonTests.swift index 8e95c3b..a7b33b2 100644 --- a/Tests/TUIkitTests/RadioButtonTests.swift +++ b/Tests/TUIkitTests/RadioButtonTests.swift @@ -386,7 +386,7 @@ struct RadioButtonGroupHandlerTests { #expect(selection == AnyHashable("y")) } - @Test("Handler ignores boundary navigation") + @Test("Handler wraps navigation at boundaries") func boundaryNavigation() { var selection = AnyHashable("opt1") let binding = Binding( @@ -403,12 +403,13 @@ struct RadioButtonGroupHandlerTests { canBeFocused: true ) - // Try to go up from first item + // Try to go up from first item — should wrap to last let upEvent = KeyEvent(key: .up) let handled = handler.handleKeyEvent(upEvent) - #expect(handled == false) - #expect(handler.focusedIndex == 0) + #expect(handled == true) + #expect(handler.focusedIndex == 1) // Wrapped to last item + #expect(selection == AnyHashable("opt2")) } @Test("Handler respects canBeFocused property")