gitbay/Views/Shared/FilterSheet.swift
128 lines · 4384 bytes
3 symbols in this file
1import SwiftUI
2
3/// The issue and MR lists' filter sheet. A `Form` with the fields both
4/// lists share — author and milestone — plus an `extra` slot for the
5/// rows each list adds: the label picker on both, and an assignee field
6/// on issues only, since `mr list` takes no `--assignee`.
7struct FilterSheet<Extra: View>: View {
8
9 let title: String
10 @Binding var author: String?
11 @Binding var milestone: String?
12 let milestones: [Milestone]
13 let onClear: () -> Void
14 @ViewBuilder var extra: Extra
15
16 @Environment(\.dismiss) private var dismiss
17
18 var body: some View {
19 NavigationStack {
20 Form {
21 extra
22 Section("Author") {
23 TextField("Username", text: $author.orEmpty)
24 .autocorrectionDisabled()
25 .textInputAutocapitalization(.never)
26 .accessibilityIdentifier("filter-author")
27 }
28 Section("Milestone") {
29 Menu {
30 Button("Any milestone") { milestone = nil }
31 Button("No milestone") { milestone = "none" }
32 ForEach(milestones) { entry in
33 Button(entry.title) { milestone = entry.title }
34 }
35 } label: {
36 HStack {
37 Text(milestone ?? "Any milestone")
38 Spacer()
39 Image(systemName: "chevron.up.chevron.down")
40 .font(.gbSans(.caption2))
41 .foregroundStyle(.secondary)
42 }
43 }
44 .accessibilityIdentifier("filter-milestone-menu")
45 }
46 Section {
47 Button("Clear filters", role: .destructive, action: onClear)
48 }
49 }
50 .navigationTitle(title)
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar {
53 ToolbarItem(placement: .confirmationAction) {
54 Button("Done") { dismiss() }
55 }
56 }
57 }
58 }
59}
60
61/// The label picker both lists' sheets add through `FilterSheet`'s
62/// `extra` slot.
63struct LabelFilterSection: View {
64
65 @Binding var label: String?
66 let labels: [IssueLabel]
67 let colors: LabelColors
68
69 var body: some View {
70 Section("Label") {
71 Menu {
72 Button("Any label") { label = nil }
73 ForEach(labels) { entry in
74 Button {
75 label = entry.name
76 } label: {
77 Text(entry.name)
78 }
79 }
80 } label: {
81 HStack {
82 if let label {
83 GBChip(label, colors.color(label))
84 } else {
85 Text("Any label")
86 }
87 Spacer()
88 Image(systemName: "chevron.up.chevron.down")
89 .font(.gbSans(.caption2))
90 .foregroundStyle(.secondary)
91 }
92 }
93 .accessibilityIdentifier("filter-label-menu")
94 }
95 }
96}
97
98/// The label and assignee rows the issue list's sheet adds through
99/// `FilterSheet`'s `extra` slot.
100struct IssueFilterExtra: View {
101
102 @Binding var label: String?
103 @Binding var assignee: String?
104 let labels: [IssueLabel]
105 let colors: LabelColors
106
107 var body: some View {
108 LabelFilterSection(label: $label, labels: labels, colors: colors)
109 Section("Assignee") {
110 TextField("Username", text: $assignee.orEmpty)
111 .autocorrectionDisabled()
112 .textInputAutocapitalization(.never)
113 .accessibilityIdentifier("filter-assignee")
114 }
115 }
116}
117
118extension Binding where Value == String? {
119 /// Bridges an optional filter field to a plain `TextField`: empty
120 /// text writes back `nil` rather than `""`, since the two are
121 /// distinct filter values (no flag sent versus `--flag ""`).
122 var orEmpty: Binding<String> {
123 Binding<String>(
124 get: { self.wrappedValue ?? "" },
125 set: { self.wrappedValue = $0.isEmpty ? nil : $0 }
126 )
127 }
128}