Refactor: Convert RadioButtonGroup to body: some View pattern

This commit is contained in:
phranck
2026-02-09 12:32:16 +01:00
parent d9010bab39
commit 4e59a36ba4
2 changed files with 111 additions and 94 deletions
+109 -92
View File
@@ -183,106 +183,31 @@ public struct RadioButtonGroup<Value: Hashable>: View {
self.isDisabled = isDisabled
}
public var body: Never {
fatalError("RadioButtonGroup renders via Renderable")
public var body: some View {
_RadioButtonGroupCore(
selection: selection,
items: items,
orientation: orientation,
focusID: focusID,
isDisabled: isDisabled
)
}
}
// MARK: - Radio Button Handler
// MARK: - Internal Core View
/// 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
var selection: Binding<AnyHashable>
var itemValues: [AnyHashable]
/// Internal view that handles the actual rendering of RadioButtonGroup.
private struct _RadioButtonGroupCore<Value: Hashable>: View, Renderable {
let selection: Binding<Value>
let items: [RadioButtonItem<Value>]
let orientation: RadioButtonOrientation
var canBeFocused: Bool
let focusID: String?
let isDisabled: Bool
/// The currently focused item index within the group.
/// Persisted across renders to maintain focus position.
var focusedIndex: Int = 0
init(
focusID: String,
selection: Binding<AnyHashable>,
itemValues: [AnyHashable],
orientation: RadioButtonOrientation,
canBeFocused: Bool
) {
self.focusID = focusID
self.selection = selection
self.itemValues = itemValues
self.orientation = orientation
self.canBeFocused = canBeFocused
// Find current focused index based on selection
if let currentIndex = itemValues.firstIndex(of: selection.wrappedValue) {
self.focusedIndex = currentIndex
}
var body: Never {
fatalError("_RadioButtonGroupCore renders via Renderable")
}
}
// MARK: - Focus Lifecycle
extension RadioButtonGroupHandler {
func onFocusLost() {
// Reset focusedIndex to the selected item when the group loses focus
if let selectedIndex = itemValues.firstIndex(of: selection.wrappedValue) {
focusedIndex = selectedIndex
}
}
}
// MARK: - Key Event Handling
extension RadioButtonGroupHandler {
func handleKeyEvent(_ event: KeyEvent) -> Bool {
switch event.key {
case .up:
// Vertical: navigate focus up (don't change selection); Horizontal: consume but do nothing
if orientation == .vertical {
focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1
}
return true
case .down:
// Vertical: navigate focus down (don't change selection); Horizontal: consume but do nothing
if orientation == .vertical {
focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0
}
return true
case .left:
// Horizontal: navigate focus left (don't change selection); Vertical: consume but do nothing
if orientation == .horizontal {
focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1
}
return true
case .right:
// Horizontal: navigate focus right (don't change selection); Vertical: consume but do nothing
if orientation == .horizontal {
focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0
}
return true
case .enter, .character(" "):
// Select the currently focused item (make it the selection)
selection.wrappedValue = itemValues[focusedIndex]
return true
default:
return false
}
}
}
// MARK: - Radio Button Group Rendering
extension RadioButtonGroup: Renderable {
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let focusManager = context.environment.focusManager
let palette = context.environment.palette
@@ -432,6 +357,98 @@ extension RadioButtonGroup: Renderable {
}
}
// 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
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(
focusID: String,
selection: Binding<AnyHashable>,
itemValues: [AnyHashable],
orientation: RadioButtonOrientation,
canBeFocused: Bool
) {
self.focusID = focusID
self.selection = selection
self.itemValues = itemValues
self.orientation = orientation
self.canBeFocused = canBeFocused
// Find current focused index based on selection
if let currentIndex = itemValues.firstIndex(of: selection.wrappedValue) {
self.focusedIndex = currentIndex
}
}
}
// MARK: - Focus Lifecycle
extension RadioButtonGroupHandler {
func onFocusLost() {
// Reset focusedIndex to the selected item when the group loses focus
if let selectedIndex = itemValues.firstIndex(of: selection.wrappedValue) {
focusedIndex = selectedIndex
}
}
}
// MARK: - Key Event Handling
extension RadioButtonGroupHandler {
func handleKeyEvent(_ event: KeyEvent) -> Bool {
switch event.key {
case .up:
// Vertical: navigate focus up (don't change selection); Horizontal: consume but do nothing
if orientation == .vertical {
focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1
}
return true
case .down:
// Vertical: navigate focus down (don't change selection); Horizontal: consume but do nothing
if orientation == .vertical {
focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0
}
return true
case .left:
// Horizontal: navigate focus left (don't change selection); Vertical: consume but do nothing
if orientation == .horizontal {
focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1
}
return true
case .right:
// Horizontal: navigate focus right (don't change selection); Vertical: consume but do nothing
if orientation == .horizontal {
focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0
}
return true
case .enter, .character(" "):
// Select the currently focused item (make it the selection)
selection.wrappedValue = itemValues[focusedIndex]
return true
default:
return false
}
}
}
// MARK: - Radio Button Group Convenience Modifiers
extension RadioButtonGroup {
@@ -283,8 +283,8 @@ Controls that need StateStorage/FocusManager:
- [x] Button: Convert to body: some View
- [x] Toggle: Convert to body: some View
- [x] Menu: Convert to body: some View
- [ ] RadioButtonGroup: Convert to body: some View
- [ ] Tests pass for each
- [x] RadioButtonGroup: Convert to body: some View
- [x] Tests pass for each
### Phase 5: Complex Controls
- [x] List: Convert to body: some View