From 1fd2c1642e59fb00b6dd2b537bd5761746a7e18e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:29:22 +0000 Subject: [PATCH] Make CategoryRow fully clickable and accessible Wrapped the CategoryRow contents in a Button to expand the hit target to the entire row, rather than just the small checkbox. Added accessibility modifiers to combine the children for VoiceOver and properly announce the selection state. Co-authored-by: acebytes <2820910+acebytes@users.noreply.github.com> --- .jules/palette.md | 3 + Sources/Cacheout/Views/CategoryRow.swift | 77 ++++++++++++------------ 2 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 .jules/palette.md diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..b511d42 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2024-04-08 - Clickable List Rows +**Learning:** In SwiftUI, wrapping an HStack in a Button and adding `.contentShape(Rectangle())` significantly increases the hit target for list items, while `.accessibilityElement(children: .combine)` ensures VoiceOver reads the entire row as a single actionable element. +**Action:** When creating clickable list rows, ensure the entire row contents are wrapped in a Button with a Rectangle content shape, and apply appropriate accessibility traits like `.isSelected`. diff --git a/Sources/Cacheout/Views/CategoryRow.swift b/Sources/Cacheout/Views/CategoryRow.swift index 2653aae..14c3e23 100644 --- a/Sources/Cacheout/Views/CategoryRow.swift +++ b/Sources/Cacheout/Views/CategoryRow.swift @@ -24,55 +24,58 @@ struct CategoryRow: View { let onToggle: () -> Void var body: some View { - HStack(spacing: 12) { - // Checkbox - Button(action: onToggle) { + Button(action: onToggle) { + HStack(spacing: 12) { + // Checkbox Image(systemName: result.isSelected ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(result.isSelected ? .blue : .secondary) - } - .buttonStyle(.plain) - .disabled(result.isEmpty) - // Icon - Image(systemName: result.category.icon) - .font(.title3) - .frame(width: 24) - .foregroundStyle(iconColor) + // Icon + Image(systemName: result.category.icon) + .font(.title3) + .frame(width: 24) + .foregroundStyle(iconColor) - // Name + description - VStack(alignment: .leading, spacing: 2) { - Text(result.category.name) - .font(.body.weight(.medium)) - if result.isEmpty { - Text("Not found") - .font(.caption) - .foregroundStyle(.tertiary) - } else { - Text(result.category.description) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + // Name + description + VStack(alignment: .leading, spacing: 2) { + Text(result.category.name) + .font(.body.weight(.medium)) + if result.isEmpty { + Text("Not found") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + Text(result.category.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } } - } - Spacer() + Spacer() - // Size - if !result.isEmpty { - Text(result.formattedSize) - .font(.body.monospacedDigit()) - .foregroundStyle(.primary) - } + // Size + if !result.isEmpty { + Text(result.formattedSize) + .font(.body.monospacedDigit()) + .foregroundStyle(.primary) + } - // Risk badge - if !result.isEmpty { - RiskBadge(level: result.category.riskLevel) + // Risk badge + if !result.isEmpty { + RiskBadge(level: result.category.riskLevel) + } } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .contentShape(Rectangle()) } - .padding(.vertical, 6) - .padding(.horizontal, 10) + .buttonStyle(.plain) + .disabled(result.isEmpty) .opacity(result.isEmpty ? 0.5 : 1) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(result.isSelected ? [.isSelected] : []) } private var iconColor: Color {