diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..00d92d1 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2024-03-25 - Making SwiftUI List Rows Fully Clickable with VoiceOver +**Learning:** In SwiftUI, placing a Button (like a checkbox) inside an HStack only makes the button's bounds interactive, causing narrow hit targets that are hard to tap. VoiceOver also treats the row as separate fragmented elements by default. Simply wrapping the HStack in a Button isn't enough because empty space in the HStack won't be clickable. +**Action:** Always wrap the entire row contents in a `Button`, apply `.contentShape(Rectangle())` to the contents to make empty space clickable, use `.buttonStyle(.plain)` on the Button to prevent unwanted text styling, and add `.accessibilityElement(children: .combine)` to create a unified VoiceOver experience. diff --git a/Sources/Cacheout/Views/CategoryRow.swift b/Sources/Cacheout/Views/CategoryRow.swift index 2653aae..89656d8 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 { diff --git a/Sources/Cacheout/Views/NodeModulesSection.swift b/Sources/Cacheout/Views/NodeModulesSection.swift index 2cba822..c5110e6 100644 --- a/Sources/Cacheout/Views/NodeModulesSection.swift +++ b/Sources/Cacheout/Views/NodeModulesSection.swift @@ -120,44 +120,47 @@ struct NodeModulesRow: View { let onToggle: () -> Void var body: some View { - HStack(spacing: 10) { - Button(action: onToggle) { + Button(action: onToggle) { + HStack(spacing: 10) { Image(systemName: item.isSelected ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(item.isSelected ? .purple : .secondary) - } - .buttonStyle(.plain) - Image(systemName: "shippingbox.fill") - .foregroundStyle(.purple.opacity(0.7)) - .frame(width: 20) + Image(systemName: "shippingbox.fill") + .foregroundStyle(.purple.opacity(0.7)) + .frame(width: 20) - VStack(alignment: .leading, spacing: 1) { - Text(item.projectName) - .font(.body.weight(.medium)) - Text(item.projectPath.path.replacingOccurrences(of: FileManager.default.homeDirectoryForCurrentUser.path, with: "~")) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) - } + VStack(alignment: .leading, spacing: 1) { + Text(item.projectName) + .font(.body.weight(.medium)) + Text(item.projectPath.path.replacingOccurrences(of: FileManager.default.homeDirectoryForCurrentUser.path, with: "~")) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } - Spacer() + Spacer() - // Stale badge - if let badge = item.staleBadge { - Text(badge) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.orange.opacity(0.15), in: Capsule()) - .foregroundStyle(.orange) - } + // Stale badge + if let badge = item.staleBadge { + Text(badge) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.15), in: Capsule()) + .foregroundStyle(.orange) + } - Text(item.formattedSize) - .font(.body.monospacedDigit()) + Text(item.formattedSize) + .font(.body.monospacedDigit()) + } + .padding(.vertical, 4) + .padding(.horizontal, 10) + .contentShape(Rectangle()) } - .padding(.vertical, 4) - .padding(.horizontal, 10) + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(item.isSelected ? .isSelected : []) } }