gitbay/Views/Shared/FilterSheet.swift
115 lines · 4021 bytes
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 only issues have, since `mr list` takes neither `--label` nor
6/// `--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 and assignee rows the issue list's sheet adds through
62/// `FilterSheet`'s `extra` slot.
63struct IssueFilterExtra: View {
64
65 @Binding var label: String?
66 @Binding var assignee: String?
67 let labels: [IssueLabel]
68 let colors: LabelColors
69
70 var body: some View {
71 Section("Label") {
72 Menu {
73 Button("Any label") { label = nil }
74 ForEach(labels) { entry in
75 Button {
76 label = entry.name
77 } label: {
78 Text(entry.name)
79 }
80 }
81 } label: {
82 HStack {
83 if let label {
84 GBChip(label, colors.color(label))
85 } else {
86 Text("Any label")
87 }
88 Spacer()
89 Image(systemName: "chevron.up.chevron.down")
90 .font(.gbSans(.caption2))
91 .foregroundStyle(.secondary)
92 }
93 }
94 .accessibilityIdentifier("filter-label-menu")
95 }
96 Section("Assignee") {
97 TextField("Username", text: $assignee.orEmpty)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 .accessibilityIdentifier("filter-assignee")
101 }
102 }
103}
104
105extension Binding where Value == String? {
106 /// Bridges an optional filter field to a plain `TextField`: empty
107 /// text writes back `nil` rather than `""`, since the two are
108 /// distinct filter values (no flag sent versus `--flag ""`).
109 var orEmpty: Binding<String> {
110 Binding<String>(
111 get: { self.wrappedValue ?? "" },
112 set: { self.wrappedValue = $0.isEmpty ? nil : $0 }
113 )
114 }
115}