gitbay/Views/Shared/ComposeSheet.swift
102 lines · 4078 bytes
1import SwiftUI
2
3/// Title-and-body editor shared by issue/MR create and edit sheets.
4struct ComposeSheet: View {
5
6 let heading: String
7 let submitLabel: String
8 let working: Bool
9 let errorMessage: String?
10 @Binding var title: String
11 @Binding var bodyText: String
12 /// Held as a real `@Binding`, not `Binding<String>?`: an optional
13 /// binding is not a dynamic property, so the sheet body never re-ran
14 /// when the value changed and the picker kept whatever it first saw.
15 @Binding var format: String
16 let showsFormat: Bool
17 /// Issue templates to start the body from; empty hides the menu.
18 let templates: [IssueTemplate]
19 let onSubmit: () -> Void
20
21 /// A nil `format` hides the picker entirely — the release editor has
22 /// no parity row and passes nothing, leaving that call site untouched.
23 init(heading: String, submitLabel: String, working: Bool, errorMessage: String?,
24 title: Binding<String>, bodyText: Binding<String>,
25 format: Binding<String>? = nil, templates: [IssueTemplate] = [],
26 onSubmit: @escaping () -> Void) {
27 self.heading = heading
28 self.submitLabel = submitLabel
29 self.working = working
30 self.errorMessage = errorMessage
31 _title = title
32 _bodyText = bodyText
33 _format = format ?? .constant("md")
34 showsFormat = format != nil
35 self.templates = templates
36 self.onSubmit = onSubmit
37 }
38
39 @Environment(\.dismiss) private var dismiss
40
41 var body: some View {
42 NavigationStack {
43 Form {
44 Section("Title") {
45 TextField("Title", text: $title, axis: .vertical)
46 .lineLimit(1...3)
47 // Titles name code: paths, commands, branch names.
48 // Autocorrection rewrites them.
49 .autocorrectionDisabled()
50 .accessibilityIdentifier("compose-title")
51 }
52 Section("Body") {
53 if !templates.isEmpty {
54 Menu {
55 ForEach(templates) { template in
56 Button(template.name) { bodyText = template.body }
57 }
58 } label: {
59 Label("Template", systemImage: "doc.text")
60 }
61 .accessibilityIdentifier("compose-template-menu")
62 }
63 TextEditor(text: $bodyText)
64 .frame(minHeight: 160)
65 .font(.gbSans(.body))
66 .autocorrectionDisabled()
67 .accessibilityIdentifier("compose-body")
68 if showsFormat {
69 Picker("Format", selection: $format) {
70 Text("Markdown").tag("md")
71 Text("Org").tag("org")
72 }
73 .pickerStyle(.segmented)
74 .accessibilityIdentifier("compose-format")
75 }
76 }
77 if let errorMessage {
78 Section {
79 GBNotice(errorMessage)
80 }
81 }
82 }
83 .navigationTitle(heading)
84 .navigationBarTitleDisplayMode(.inline)
85 .toolbar {
86 ToolbarItem(placement: .cancellationAction) {
87 Button("Cancel") { dismiss() }
88 }
89 ToolbarItem(placement: .confirmationAction) {
90 if working {
91 ProgressView()
92 } else {
93 Button(submitLabel, action: onSubmit)
94 .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
95 .accessibilityIdentifier("compose-submit")
96 }
97 }
98 }
99 .interactiveDismissDisabled(working)
100 }
101 }
102}