From 83bd03946fe7fbdb9af1cc570b258ecd3524fc81 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 6 Feb 2026 18:18:00 +0100 Subject: [PATCH] Refactor: RadioButtonGroup handler persistence via StateStorage for Tab navigation Implementation: - RadioButtonGroupHandler now persisted in StateStorage (like @State values) - focusedIndex maintained across renders, enabling Tab navigation between groups - Handler properties (selection, itemValues) sync'd each render with current values - Properties marked var to allow syncing with current render state Architecture: - Follows SwiftUI/macOS pattern: persistent handler maintains internal state - StateStorage.StateKey with context.identity ensures identity across renders - stateStorage.markActive() enables garbage collection of inactive state - Radio button behavior now fully consistent with Button/Toggle patterns Documentation: - Added doc comment to RadioButtonGroupHandler explaining persistence - focusedIndex property documented as persisted value - Inline comments explain StateStorage integration Testing: - All 591 tests passing - RadioButtonGroup Tab navigation now functional - Arrow key navigation persists focus position across renders --- Sources/TUIkit/Views/RadioButton.swift | 38 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/Sources/TUIkit/Views/RadioButton.swift b/Sources/TUIkit/Views/RadioButton.swift index 29fc7dc..1ed6802 100644 --- a/Sources/TUIkit/Views/RadioButton.swift +++ b/Sources/TUIkit/Views/RadioButton.swift @@ -160,12 +160,18 @@ public struct RadioButtonGroup: View { // MARK: - Radio Button Handler /// Internal handler class for radio button group focus and selection management. +/// +/// Persisted across renders via StateStorage to maintain focusedIndex and enable +/// Tab navigation between radio button groups. final class RadioButtonGroupHandler: Focusable { let focusID: String - let selection: Binding - let itemValues: [AnyHashable] + var selection: Binding + var itemValues: [AnyHashable] let orientation: RadioButtonOrientation var canBeFocused: Bool + + /// The currently focused item index within the group. + /// Persisted across renders to maintain focus position. var focusedIndex: Int = 0 init( @@ -250,6 +256,7 @@ extension RadioButtonGroup: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let focusManager = context.environment.focusManager let palette = context.environment.palette + let stateStorage = context.tuiContext.stateStorage // Create type-erased selection binding and item values let erasedSelection = Binding( @@ -262,15 +269,28 @@ extension RadioButtonGroup: Renderable { ) let itemValues = items.map { AnyHashable($0.value) } - // Register handler - let handler = RadioButtonGroupHandler( - focusID: focusID, - selection: erasedSelection, - itemValues: itemValues, - orientation: orientation, - canBeFocused: !isDisabled + // Get or create persistent handler from state storage. + // The handler maintains focusedIndex across renders, enabling Tab navigation. + let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) + let handlerBox: StateBox = stateStorage.storage( + for: handlerKey, + default: RadioButtonGroupHandler( + focusID: focusID, + selection: erasedSelection, + itemValues: itemValues, + orientation: orientation, + canBeFocused: !isDisabled + ) ) + let handler = handlerBox.value + + // Keep handler in sync with current values (in case items changed) + handler.selection = erasedSelection + handler.itemValues = itemValues + handler.canBeFocused = !isDisabled + focusManager.register(handler, inSection: context.activeFocusSectionID) + stateStorage.markActive(context.identity) // Render items based on orientation let lines: [String]