// SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct BreadcrumbTrailItem: Identifiable, Hashable { let id: String let title: String } /// A breadcrumb path. When it is wider than its space, the middle crumbs move into a menu. /// /// If even the shortest form does not fit, as at the largest text sizes, the row scrolls. struct BreadcrumbTrail: View { let items: [BreadcrumbTrailItem] let onSelect: (BreadcrumbTrailItem) -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion /// A crumb is a short line of text, too small to hit. This padding widens what answers to a /// touch, and the same padding taken back keeps the trail as compact as it was. private let touchPadding: CGFloat = 13 var body: some View { ViewThatFits(in: .horizontal) { fullRow ForEach(BreadcrumbTrailMath.collapsedTails(count: items.count), id: \.self) { tail in collapsedRow(tail: tail) } ScrollView(.horizontal) { fullRow } .scrollIndicators(.hidden) } .font(.subheadline) .animation(reduceMotion ? nil : .snappy, value: items) } private var fullRow: some View { HStack(spacing: 6) { crumbs(items[...]) } } private func collapsedRow(tail: Int) -> some View { let parts = BreadcrumbTrailMath.split(items, tail: tail) return HStack(spacing: 6) { crumbs(parts.head) separator Menu { ForEach(parts.hidden) { item in Button(item.title) { onSelect(item) } } } label: { Image(systemName: "ellipsis.circle.fill") .symbolRenderingMode(.hierarchical) .imageScale(.large) .padding(touchPadding) .contentShape(.rect) .padding(-touchPadding) } .accessibilityLabel("More") separator crumbs(parts.tail) } } @ViewBuilder private func crumbs(_ slice: ArraySlice) -> some View { ForEach(slice) { item in if item != slice.first { separator } if item == items.last { Text(item.title) .fontWeight(.semibold) .lineLimit(1) .fixedSize() .accessibilityAddTraits(.isSelected) } else { Button { onSelect(item) } label: { Text(item.title) .padding(.vertical, touchPadding) .contentShape(.rect) .padding(.vertical, -touchPadding) } .lineLimit(1) .fixedSize() } } } private var separator: some View { Image(systemName: "chevron.compact.forward") .foregroundStyle(.tertiary) .accessibilityHidden(true) } } enum BreadcrumbTrailMath { /// How many trailing crumbs each collapsed form keeps, from the widest form to the narrowest. static func collapsedTails(count: Int) -> [Int] { guard count > 2 else { return [] } return Array((1...min(count - 2, 3)).reversed()) } /// Keeps the first crumb and the last `tail` crumbs. The rest go in the menu. static func split( _ items: [Element], tail: Int ) -> (head: ArraySlice, hidden: ArraySlice, tail: ArraySlice) { guard let first = items.indices.first else { return ([], [], []) } let tailStart = max(items.count - max(tail, 0), first + 1) return (items[first...first], items[(first + 1)..