Feat: RadioButtonGroup wrapping navigation for seamless cycling

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)
This commit is contained in:
phranck
2026-02-06 19:17:46 +01:00
parent 86fb78d2b7
commit f1b29c17bf
2 changed files with 21 additions and 28 deletions
+16 -24
View File
@@ -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
+5 -4
View File
@@ -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")