241 lines
8.1 KiB
Swift
241 lines
8.1 KiB
Swift
import SwiftUI
|
|
import TermindPresentation
|
|
|
|
struct MobileTagField: View {
|
|
@Binding var tags: [String]
|
|
let suggestions: [String]
|
|
|
|
@State private var query = ""
|
|
@FocusState private var isFocused: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if !tags.isEmpty {
|
|
MobileTagFlowLayout(spacing: 6) {
|
|
ForEach(tags, id: \.self) { tag in
|
|
MobileRemovableTagChip(tag: tag) {
|
|
tags = MobileTagFieldPolicy.removing(tag, from: tags)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "number")
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
|
|
TextField(text: $query.singleLineText, prompt: Text("Add a tag…"), axis: .horizontal) {
|
|
EmptyView()
|
|
}
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.submitLabel(.done)
|
|
.focused($isFocused)
|
|
.onSubmit(commitQuery)
|
|
.accessibilityLabel(Text("Add a tag…"))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(minHeight: 44)
|
|
|
|
if isFocused && !rows.isEmpty {
|
|
Divider()
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(rows, id: \.self) { row in
|
|
Button {
|
|
commit(row)
|
|
} label: {
|
|
MobileTagSuggestionRow(row: row)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: min(CGFloat(rows.count), 4) * 44)
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
}
|
|
}
|
|
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 10))
|
|
}
|
|
}
|
|
|
|
private var rows: [MobileTagFieldPolicy.Row] {
|
|
MobileTagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query)
|
|
}
|
|
|
|
private func commitQuery() {
|
|
guard let tag = MobileTagFieldPolicy.submission(query: query, suggestions: suggestions) else {
|
|
isFocused = false
|
|
return
|
|
}
|
|
add(tag)
|
|
}
|
|
|
|
private func commit(_ row: MobileTagFieldPolicy.Row) {
|
|
switch row {
|
|
case .existing(let tag), .create(let tag):
|
|
add(tag)
|
|
}
|
|
}
|
|
|
|
private func add(_ tag: String) {
|
|
tags = MobileTagFieldPolicy.adding(tag, to: tags)
|
|
query = ""
|
|
isFocused = true
|
|
}
|
|
}
|
|
|
|
nonisolated enum MobileTagFieldPolicy {
|
|
enum Row: Hashable, Sendable {
|
|
case existing(String)
|
|
case create(String)
|
|
}
|
|
|
|
static func rows(tags: [String], suggestions: [String], query: String) -> [Row] {
|
|
let trimmedQuery = query.trimmingCharacters(in: .whitespaces)
|
|
let taken = Set(tags.map { $0.lowercased() })
|
|
let matches =
|
|
suggestions
|
|
.filter { !taken.contains($0.lowercased()) }
|
|
.filter { trimmedQuery.isEmpty || $0.localizedCaseInsensitiveContains(trimmedQuery) }
|
|
|
|
guard !trimmedQuery.isEmpty else { return matches.map(Row.existing) }
|
|
let spokenFor = taken.union(suggestions.map { $0.lowercased() })
|
|
guard !spokenFor.contains(trimmedQuery.lowercased()) else { return matches.map(Row.existing) }
|
|
return matches.map(Row.existing) + [.create(trimmedQuery)]
|
|
}
|
|
|
|
static func submission(query: String, suggestions: [String]) -> String? {
|
|
let trimmed = query.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return suggestions.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame } ?? trimmed
|
|
}
|
|
|
|
static func adding(_ tag: String, to tags: [String]) -> [String] {
|
|
let trimmed = tag.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty,
|
|
!tags.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame })
|
|
else { return tags }
|
|
return tags + [trimmed]
|
|
}
|
|
|
|
static func removing(_ tag: String, from tags: [String]) -> [String] {
|
|
tags.filter { $0 != tag }
|
|
}
|
|
}
|
|
|
|
private struct MobileTagSuggestionRow: View {
|
|
let row: MobileTagFieldPolicy.Row
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
switch row {
|
|
case .existing(let tag):
|
|
Image(systemName: "number")
|
|
.foregroundStyle(.secondary)
|
|
Text(tag)
|
|
case .create(let tag):
|
|
Image(systemName: "plus.circle")
|
|
.foregroundStyle(.secondary)
|
|
Text("Create “\(tag)”")
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 12)
|
|
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
|
.contentShape(.rect)
|
|
}
|
|
}
|
|
|
|
private struct MobileRemovableTagChip: View {
|
|
let tag: String
|
|
let remove: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Text(tag)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
|
|
Button(action: remove) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 28, height: 44)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("Remove tag \(tag)"))
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.leading, 10)
|
|
.padding(.trailing, 2)
|
|
.background(.quaternary.opacity(0.6), in: .capsule)
|
|
}
|
|
}
|
|
|
|
private struct MobileTagFlowLayout: Layout {
|
|
let spacing: CGFloat
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let naturalWidth = subviews.reduce(CGFloat.zero) { width, subview in
|
|
width + subview.sizeThatFits(.unspecified).width + (width == 0 ? 0 : spacing)
|
|
}
|
|
let proposedWidth = proposal.width ?? naturalWidth
|
|
let width = proposedWidth.isFinite ? max(0, proposedWidth) : naturalWidth
|
|
let rows = arrange(subviews: subviews, in: width)
|
|
let height =
|
|
rows.reduce(CGFloat.zero) { $0 + $1.height }
|
|
+ spacing * CGFloat(max(rows.count - 1, 0))
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
func placeSubviews(
|
|
in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
|
|
) {
|
|
var y = bounds.minY
|
|
for row in arrange(subviews: subviews, in: bounds.width) {
|
|
var x = bounds.minX
|
|
for index in row.indices {
|
|
let size = subviews[index].sizeThatFits(.unspecified)
|
|
subviews[index].place(
|
|
at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size))
|
|
x += size.width + spacing
|
|
}
|
|
y += row.height + spacing
|
|
}
|
|
}
|
|
|
|
private struct RowRun {
|
|
var indices: [Int] = []
|
|
var height: CGFloat = 0
|
|
}
|
|
|
|
private func arrange(subviews: Subviews, in width: CGFloat) -> [RowRun] {
|
|
var rows: [RowRun] = []
|
|
var current = RowRun()
|
|
var x: CGFloat = 0
|
|
|
|
for index in subviews.indices {
|
|
let size = subviews[index].sizeThatFits(.unspecified)
|
|
if !current.indices.isEmpty && x + size.width > width {
|
|
rows.append(current)
|
|
current = RowRun()
|
|
x = 0
|
|
}
|
|
current.indices.append(index)
|
|
current.height = max(current.height, size.height)
|
|
x += size.width + spacing
|
|
}
|
|
|
|
if !current.indices.isEmpty { rows.append(current) }
|
|
return rows
|
|
}
|
|
}
|