import SwiftUI // MARK: - Presentation /// Whether *this* card face is the one showing the open **Labels ▸ More…** dialog. /// /// `styleEditorPresentation`'s shape and its reasoning, one session over and simpler for it: a label /// session names exactly one card, so the anchor test is an identity comparison rather than a walk for /// the first live member of a set. The setter is narrowed to this anchor's own dismissal for that /// function's reason exactly — a session that has moved on must not be discarded by a surface it is no /// longer about. @MainActor func labelEditorPresentation(_ store: BoardStore, anchor: ItemID) -> Binding { Binding( get: { store.transient.labelEditor?.cardID == anchor }, set: { presented in guard !presented, store.transient.labelEditor?.cardID == anchor else { return } store.transient.discardLabelEditor() } ) } // MARK: - The dialog /// **Labels ▸ More…** — every label the board uses, each with a toggle, plus a field that creates a /// new one (the owner's card-menu spec: "More… (show a dialog with a list of all used labels + /// ability to create new)"). /// /// ### Why it exists beside the twelve /// /// The submenu's rows are a *shortcut* — the labels this user is most likely to want, ranked /// (`LabelRanking`). This is the **inventory**: every label in the board's universe, however rarely /// used, in an order built for looking one up rather than for reaching the common ones fast /// (`LabelIndex.alphabetical`). It is also the only surface on the board side that can mint a label /// the board has never used — the twelve can only ever offer what already exists. /// /// ### A popover, not a sheet /// /// "Dialog" is the owner's word for the shape, not necessarily for the presentation. A sheet would /// block the board window for a gesture whose whole character is quick tagging, and it would have /// nothing to anchor to — the user right-clicked a specific card and the answer belongs beside it. /// Style… is the precedent in this exact position: same anchor, same session-backed lifecycle, same /// dismissal on click-away. Flagged for owner review if a real modal was meant. /// /// ### One card /// /// Every row is a checkmark, and a checkmark states a fact about one card — see /// `CardFaceView.labelsMenu` for the argument and why it is also what keeps the menu render-safe. struct LabelPickerPopover: View { let store: BoardStore let recents: LabelRecents /// What is typed in the create field. @State private var draft = "" @FocusState private var fieldFocused: Bool var body: some View { // Empty for the frame between a session ending and the popover's own dismissal landing — // `StyleEditorPopover`'s own formality, and for its reason. if let session = store.transient.labelEditor { content(for: session.cardID) } } private func content(for cardID: ItemID) -> some View { let current = store.labels(ofCard: cardID) let universe = store.labelIndex.alphabetical return VStack(alignment: .leading, spacing: 8) { Text("Labels") .font(.caption.weight(.semibold)) .textCase(.uppercase) .foregroundStyle(.secondary) if universe.isEmpty { Text("This board has no labels yet.") .font(.caption) .foregroundStyle(.secondary) } else { // A scroll view rather than a bare stack: the universe has no bound, and a popover // that grows past the screen is a popover that cannot be dismissed by its own bottom // edge. The height is a cap, not a size — a board with three labels draws three rows. ScrollView(.vertical) { VStack(alignment: .leading, spacing: 2) { ForEach(universe, id: \.self) { name in Toggle(name, isOn: binding(for: name, onCard: cardID, current: current)) .toggleStyle(.checkbox) .lineLimit(1) .truncationMode(.middle) } } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 220) } Divider() HStack(spacing: 6) { TextField("New label", text: $draft) .textFieldStyle(.roundedBorder) .focused($fieldFocused) .onSubmit { create(onCard: cardID) } .accessibilityLabel("New Label") Button("Add") { create(onCard: cardID) } .disabled(CardLabels.normalized(draft) == nil) } .disabled(!store.acceptsBoardMutations) } .padding(12) .frame(width: 240) .onAppear { fieldFocused = true } } /// One row's checkmark: reads the card's current list, writes through the one funnel. /// /// `current` is passed in rather than re-read per row — one snapshot lookup for the whole dialog, /// so twenty rows cost one walk instead of twenty. private func binding(for name: String, onCard cardID: ItemID, current: [String]) -> Binding { Binding( get: { CardLabels.contains(name, in: current) }, set: { _ in LabelCommand.toggle(name, onCard: cardID, in: store, recents: recents) } ) } /// The create field's landing. It **applies** rather than toggles — the user typed a name into a /// box labelled "New label", which is a request to put it on the card, never to take it off /// (`LabelCommand.add`, which also resolves the board's own spelling for a name that merely looks /// new). /// /// The field clears and keeps focus: adding two labels in a row is the common case, and the /// popover's own dismissal is click-away or Escape. private func create(onCard cardID: ItemID) { guard store.acceptsBoardMutations, CardLabels.normalized(draft) != nil else { return } LabelCommand.add(draft, onCard: cardID, in: store, recents: recents) draft = "" fieldFocused = true } }