Refactor: Make ItemListHandler generic over SelectionValue

- Replace AnyHashable type erasure with generic SelectionValue parameter
- Remove configureSelectionBindings and assign typed bindings directly
- Change itemIDs from [AnyHashable] to [SelectionValue?] for nil-safe non-selectable rows
- Update _ListCore, _TableCore, and tests to use type-safe selection
This commit is contained in:
phranck
2026-02-14 17:22:08 +01:00
parent b404a62731
commit cbf5b9d5b6
4 changed files with 64 additions and 92 deletions
+10 -40
View File
@@ -54,7 +54,7 @@ public enum SelectionMode: Sendable {
/// | PageUp | Move up by viewport height |
/// | PageDown | Move down by viewport height |
/// | Enter/Space | Toggle selection at focused index |
final class ItemListHandler: Focusable {
final class ItemListHandler<SelectionValue: Hashable>: Focusable {
/// The unique identifier for this focusable element.
let focusID: String
@@ -77,13 +77,15 @@ final class ItemListHandler: Focusable {
var scrollOffset: Int = 0
/// Binding for single selection mode (optional ID).
var singleSelection: Binding<AnyHashable?>?
var singleSelection: Binding<SelectionValue?>?
/// Binding for multi-selection mode (Set of IDs).
var multiSelection: Binding<Set<AnyHashable>>?
var multiSelection: Binding<Set<SelectionValue>>?
/// Maps item indices to their IDs for selection management.
var itemIDs: [AnyHashable] = []
///
/// Entries are `nil` for non-selectable rows (e.g. section headers/footers in List).
var itemIDs: [SelectionValue?] = []
/// The set of indices that can be selected and focused.
///
@@ -282,40 +284,9 @@ extension ItemListHandler {
extension ItemListHandler {
/// Toggles the selection state at the focused index.
/// Configures selection bindings by converting typed bindings to type-erased `AnyHashable` bindings.
///
/// This helper eliminates duplicated binding conversion code in `_ListCore` and `_TableCore`.
///
/// - Parameters:
/// - single: An optional single-selection binding.
/// - multi: An optional multi-selection binding.
func configureSelectionBindings<T: Hashable>(
single: Binding<T?>?,
multi: Binding<Set<T>>?
) {
if let binding = single {
singleSelection = Binding<AnyHashable?>(
get: { binding.wrappedValue.map { AnyHashable($0) } },
set: { newValue in
binding.wrappedValue = newValue?.base as? T
}
)
}
if let binding = multi {
multiSelection = Binding<Set<AnyHashable>>(
get: { Set(binding.wrappedValue.map { AnyHashable($0) }) },
set: { newValue in
binding.wrappedValue = Set(newValue.compactMap { $0.base as? T })
}
)
}
}
func toggleSelectionAtFocusedIndex() {
guard focusedIndex >= 0 && focusedIndex < itemIDs.count else { return }
let itemID = itemIDs[focusedIndex]
guard focusedIndex >= 0 && focusedIndex < itemIDs.count,
let itemID = itemIDs[focusedIndex] else { return }
switch selectionMode {
case .single:
@@ -344,9 +315,8 @@ extension ItemListHandler {
/// - Parameter index: The item index.
/// - Returns: True if the item is selected.
func isSelected(at index: Int) -> Bool {
guard index >= 0 && index < itemIDs.count else { return false }
let itemID = itemIDs[index]
guard index >= 0 && index < itemIDs.count,
let itemID = itemIDs[index] else { return false }
switch selectionMode {
case .single:
+5 -4
View File
@@ -225,7 +225,7 @@ private struct _TableCore<Value: Identifiable & Sendable>: View, Renderable wher
// Get or create persistent handler
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler
let handlerBox: StateBox<ItemListHandler> = stateStorage.storage(
let handlerBox: StateBox<ItemListHandler<Value.ID>> = stateStorage.storage(
for: handlerKey,
default: ItemListHandler(
focusID: persistedFocusID,
@@ -241,10 +241,11 @@ private struct _TableCore<Value: Identifiable & Sendable>: View, Renderable wher
handler.itemCount = data.count
handler.viewportHeight = viewportHeight
handler.canBeFocused = !isDisabled
handler.itemIDs = data.map { AnyHashable($0.id) }
handler.itemIDs = data.map { $0.id }
// Set up selection bindings
handler.configureSelectionBindings(single: singleSelection, multi: multiSelection)
// Assign selection bindings directly (type-safe, no AnyHashable conversion)
handler.singleSelection = singleSelection
handler.multiSelection = multiSelection
// Ensure focused item is visible
handler.ensureFocusedItemVisible()
+9 -8
View File
@@ -54,7 +54,7 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
// Get or create persistent handler
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler
let handlerBox: StateBox<ItemListHandler> = stateStorage.storage(
let handlerBox: StateBox<ItemListHandler<SelectionValue>> = stateStorage.storage(
for: handlerKey,
default: ItemListHandler(
focusID: persistedFocusID,
@@ -73,22 +73,23 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
// Build selectableIndices set and itemIDs from typed rows
var selectableIndices = Set<Int>()
var itemIDs: [AnyHashable] = []
var itemIDs: [SelectionValue?] = []
for (index, row) in rows.enumerated() {
if let id = row.id {
// Content row: use actual ID
itemIDs.append(AnyHashable(id))
itemIDs.append(id)
selectableIndices.insert(index)
} else {
// Header/footer: use index as placeholder (never selected)
itemIDs.append(AnyHashable(index))
// Header/footer: nil (non-selectable)
itemIDs.append(nil)
}
}
handler.itemIDs = itemIDs
handler.selectableIndices = selectableIndices
// Set up selection bindings
handler.configureSelectionBindings(single: singleSelection, multi: multiSelection)
// Assign selection bindings directly (type-safe, no AnyHashable conversion)
handler.singleSelection = singleSelection
handler.multiSelection = multiSelection
// Ensure focused item is visible
handler.ensureFocusedItemVisible()
@@ -285,7 +286,7 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
private func calculateVisibleRows(
rows: [SelectableListRow<SelectionValue>],
handler: ItemListHandler,
handler: ItemListHandler<SelectionValue>,
viewportHeight: Int
) -> [(index: Int, row: SelectableListRow<SelectionValue>)] {
var result: [(Int, SelectableListRow<SelectionValue>)] = []
+40 -40
View File
@@ -16,7 +16,7 @@ struct ItemListHandlerNavigationTests {
@Test("Down arrow moves focus forward")
func moveDownSimple() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 5,
viewportHeight: 3,
@@ -32,7 +32,7 @@ struct ItemListHandlerNavigationTests {
@Test("Up arrow moves focus backward")
func moveUpSimple() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 5,
viewportHeight: 3,
@@ -49,7 +49,7 @@ struct ItemListHandlerNavigationTests {
@Test("Down arrow wraps to start at end")
func wrapDownToStart() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
@@ -65,7 +65,7 @@ struct ItemListHandlerNavigationTests {
@Test("Up arrow wraps to end at start")
func wrapUpToEnd() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
@@ -81,7 +81,7 @@ struct ItemListHandlerNavigationTests {
@Test("Home key jumps to first item")
func homeJumpsToFirst() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 5,
@@ -98,7 +98,7 @@ struct ItemListHandlerNavigationTests {
@Test("End key jumps to last item")
func endJumpsToLast() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 5,
@@ -115,7 +115,7 @@ struct ItemListHandlerNavigationTests {
@Test("PageDown moves by viewport height")
func pageDownMovesViewport() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 20,
viewportHeight: 5,
@@ -132,7 +132,7 @@ struct ItemListHandlerNavigationTests {
@Test("PageUp moves by viewport height")
func pageUpMovesViewport() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 20,
viewportHeight: 5,
@@ -149,7 +149,7 @@ struct ItemListHandlerNavigationTests {
@Test("PageDown clamps at end without wrapping")
func pageDownClampsAtEnd() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 5,
@@ -165,7 +165,7 @@ struct ItemListHandlerNavigationTests {
@Test("PageUp clamps at start without wrapping")
func pageUpClampsAtStart() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 5,
@@ -181,7 +181,7 @@ struct ItemListHandlerNavigationTests {
@Test("Empty list handles navigation gracefully")
func emptyListNavigation() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 0,
viewportHeight: 5,
@@ -204,14 +204,14 @@ struct ItemListHandlerSelectionTests {
@Test("Enter toggles single selection")
func enterTogglesSingle() {
var selectedID: AnyHashable?
let handler = ItemListHandler(
var selectedID: String?
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .single
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.singleSelection = Binding(
get: { selectedID },
set: { selectedID = $0 }
@@ -222,19 +222,19 @@ struct ItemListHandlerSelectionTests {
let handled = handler.handleKeyEvent(event)
#expect(handled == true)
#expect(selectedID == AnyHashable("b"))
#expect(selectedID == "b")
}
@Test("Space toggles single selection")
func spaceTogglesSingle() {
var selectedID: AnyHashable?
let handler = ItemListHandler(
var selectedID: String?
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .single
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.singleSelection = Binding(
get: { selectedID },
set: { selectedID = $0 }
@@ -245,19 +245,19 @@ struct ItemListHandlerSelectionTests {
let handled = handler.handleKeyEvent(event)
#expect(handled == true)
#expect(selectedID == AnyHashable("c"))
#expect(selectedID == "c")
}
@Test("Single selection can be deselected by selecting again")
func singleDeselect() {
var selectedID: AnyHashable? = AnyHashable("a")
let handler = ItemListHandler(
var selectedID: String? = "a"
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .single
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.singleSelection = Binding(
get: { selectedID },
set: { selectedID = $0 }
@@ -272,14 +272,14 @@ struct ItemListHandlerSelectionTests {
@Test("Multi selection adds to set")
func multiSelectionAdds() {
var selected: Set<AnyHashable> = []
let handler = ItemListHandler(
var selected: Set<String> = []
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .multi
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.multiSelection = Binding(
get: { selected },
set: { selected = $0 }
@@ -289,20 +289,20 @@ struct ItemListHandlerSelectionTests {
let event = KeyEvent(key: .enter)
_ = handler.handleKeyEvent(event)
#expect(selected.contains(AnyHashable("b")))
#expect(selected.contains("b"))
#expect(selected.count == 1)
}
@Test("Multi selection toggles items")
func multiSelectionToggles() {
var selected: Set<AnyHashable> = [AnyHashable("b")]
let handler = ItemListHandler(
var selected: Set<String> = ["b"]
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .multi
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.multiSelection = Binding(
get: { selected },
set: { selected = $0 }
@@ -312,20 +312,20 @@ struct ItemListHandlerSelectionTests {
let event = KeyEvent(key: .enter)
_ = handler.handleKeyEvent(event)
#expect(!selected.contains(AnyHashable("b"))) // Removed
#expect(!selected.contains("b")) // Removed
#expect(selected.isEmpty)
}
@Test("isSelected returns correct state")
func isSelectedReturnsCorrectState() {
var selectedID: AnyHashable? = AnyHashable("b")
let handler = ItemListHandler(
var selectedID: String? = "b"
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
selectionMode: .single
)
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
handler.itemIDs = ["a", "b", "c"]
handler.singleSelection = Binding(
get: { selectedID },
set: { selectedID = $0 }
@@ -338,7 +338,7 @@ struct ItemListHandlerSelectionTests {
@Test("isFocused returns correct state")
func isFocusedReturnsCorrectState() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 3,
viewportHeight: 3,
@@ -360,7 +360,7 @@ struct ItemListHandlerScrollTests {
@Test("Scroll offset adjusts when focus moves below viewport")
func scrollDownOnFocusBelowViewport() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 3,
@@ -374,7 +374,7 @@ struct ItemListHandlerScrollTests {
@Test("Scroll offset adjusts when focus moves above viewport")
func scrollUpOnFocusAboveViewport() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 3,
@@ -389,7 +389,7 @@ struct ItemListHandlerScrollTests {
@Test("hasContentAbove returns correct state")
func hasContentAboveState() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 3,
@@ -405,7 +405,7 @@ struct ItemListHandlerScrollTests {
@Test("hasContentBelow returns correct state")
func hasContentBelowState() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 3,
@@ -421,7 +421,7 @@ struct ItemListHandlerScrollTests {
@Test("visibleRange returns correct range")
func visibleRangeCorrect() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 10,
viewportHeight: 3,
@@ -435,7 +435,7 @@ struct ItemListHandlerScrollTests {
@Test("visibleRange clamps to item count")
func visibleRangeClampsToItemCount() {
let handler = ItemListHandler(
let handler = ItemListHandler<String>(
focusID: "test",
itemCount: 5,
viewportHeight: 10,