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
This commit is contained in:
phranck
2026-02-06 18:18:00 +01:00
parent 58ad43c6ea
commit 83bd03946f
+29 -9
View File
@@ -160,12 +160,18 @@ public struct RadioButtonGroup<Value: Hashable>: 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<AnyHashable>
let itemValues: [AnyHashable]
var selection: Binding<AnyHashable>
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<AnyHashable>(
@@ -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<RadioButtonGroupHandler> = 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]