The card menu learns to tag — a labels submenu of twelve checkmarks, and Copy Link moves in beside its mirror
Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the board carry each one, tie-broken by what this user reached for last, each a checkmark that says whether *this* card already has it. More… opens the whole inventory beside the card, in lookup order, with a field that mints a name the board has never used. The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A checkmark is a claim about one card, and three cards where two carry `bug` have no honest checked state — the rows that widen are the ones that say what they will do rather than what is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt on every ordinary pass of every card face, so reading the selection there would resubscribe the whole board. The board-sized half of the ranking is cached on the store and gated on equality; what runs per face is bounded by how many distinct labels exist, not by how many cards do. Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special — the placement the owner's latest layout asks for, and the one the row's own note has been waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it is an action that changed doors, not a door. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -203,3 +203,40 @@ public enum LabelRanking {
|
||||
return ordered.prefix(limit).map(\.name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The More… dialog's session
|
||||
|
||||
/// **The open More… dialog's card** — the `labels` submenu's escape hatch, which lists every used
|
||||
/// label with a toggle plus a field to create a new one (the owner's card menu spec).
|
||||
///
|
||||
/// `StyleEditorSession`'s shape and its whole lifecycle argument, narrowed to one card: it lives in
|
||||
/// `TransientBoardState` rather than in a view because a card face is inside a lazy stack that may be
|
||||
/// recycled out from under an open surface, and because the reload rule has to be able to take the
|
||||
/// dialog down when its subject leaves the board.
|
||||
///
|
||||
/// **One card, not a target set**, which is the whole difference from the style session and the same
|
||||
/// decision the submenu itself makes: every row in this dialog is a *checkmark*, and a checkmark has
|
||||
/// to state a fact about one card. See `CardFaceView.labelsMenu` for the argument in full.
|
||||
public struct LabelEditorSession: Sendable, Equatable {
|
||||
|
||||
/// Whose labels are being edited. `let`, for `StyleEditorSession.target`'s reason: a session whose
|
||||
/// card has vanished is *gone*, never re-aimed at another one.
|
||||
public let cardID: ItemID
|
||||
|
||||
public init(cardID: ItemID) {
|
||||
self.cardID = cardID
|
||||
}
|
||||
|
||||
/// This session re-grounded on a freshly applied snapshot, or `nil` when its card is no longer a
|
||||
/// live board card — which the presenting anchor reads as "dismiss".
|
||||
///
|
||||
/// Liveness is **effective, ancestor-walked**, for free and for `StyleEditorSession`'s reason: the
|
||||
/// resolution runs through `ItemReferenceSet`, so a card under a lane somebody just trashed leaves
|
||||
/// the set exactly as it leaves the selection. A card moved into the trash dismisses too, which is
|
||||
/// right — `BoardStore.setLabels` refuses a trashed card, so a dialog left open over one would be
|
||||
/// a surface offering writes it cannot make.
|
||||
public func resolved(against snapshot: BoardModel) -> LabelEditorSession? {
|
||||
let live = ItemReferenceSet(ids: [cardID], container: .board).resolved(against: snapshot).ids
|
||||
return live.contains(cardID) ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,17 @@ public final class TransientBoardState {
|
||||
/// session assignable from anywhere could be re-aimed behind the reload rule's back.
|
||||
public private(set) var styleEditor: StyleEditorSession?
|
||||
|
||||
/// The open **Labels ▸ More…** dialog's card, or `nil` when none is open (the card context menu's
|
||||
/// `labels` submenu; `FrontmatterKeys.labels`, activated 2026-08-09). `styleEditor`'s neighbour in
|
||||
/// every respect — same reason it lives here rather than in a view, same `private(set)`, same
|
||||
/// re-grounding below — narrowed to one card because every row in that dialog is a checkmark about
|
||||
/// one card (`LabelEditorSession`).
|
||||
///
|
||||
/// **Not an inline editor either**, `styleEditor`'s own note: the dialog's create field holds text,
|
||||
/// but `isEditingInline` is the answer to "may board commands run" and this surface is opened *by*
|
||||
/// one of them.
|
||||
public private(set) var labelEditor: LabelEditorSession?
|
||||
|
||||
/// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one
|
||||
/// boolean: "while an inline title editor — rename or the new-card placeholder — is focused,
|
||||
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu
|
||||
@@ -618,6 +629,22 @@ public final class TransientBoardState {
|
||||
styleEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - The labels dialog's lifecycle
|
||||
|
||||
/// Opens **Labels ▸ More…** on one card — the card context menu's row, and its only caller.
|
||||
///
|
||||
/// Replacing any session already open, `beginStyleEditor`'s rule and its reason: there is one
|
||||
/// dialog, so a second More… is the first one being re-aimed by a fresh gesture.
|
||||
public func beginLabelEditor(forCard cardID: ItemID) {
|
||||
labelEditor = LabelEditorSession(cardID: cardID)
|
||||
}
|
||||
|
||||
/// Closes it — the user dismissing it, and the anchor's response to a session the reload rule
|
||||
/// emptied.
|
||||
public func discardLabelEditor() {
|
||||
labelEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
|
||||
@@ -691,6 +718,7 @@ public final class TransientBoardState {
|
||||
pendingCut = pendingCut.resolved(against: snapshot)
|
||||
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
||||
styleEditor = styleEditor?.resolved(against: snapshot)
|
||||
labelEditor = labelEditor?.resolved(against: snapshot)
|
||||
|
||||
// One universe computed once and asked three questions — the rename target's container, the
|
||||
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
||||
|
||||
@@ -373,6 +373,14 @@ struct CardFaceView: View, Equatable {
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
// Labels ▸ More… — the second session-backed popover this face can host, mounted
|
||||
// exactly like the first (2026-08-09, card 28c79ffe). The two can never be open at
|
||||
// once: each is opened by a row of the same menu, and opening either replaces nothing
|
||||
// of the other's — but a user cannot press two menu rows in one gesture, and each
|
||||
// session's own `begin` replaces only its own kind.
|
||||
.popover(isPresented: labelEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
LabelPickerPopover(store: store, recents: appModel.labelRecents)
|
||||
}
|
||||
case let .trash(confirmations):
|
||||
face
|
||||
.contextMenu { trashMenu(confirmations: confirmations) }
|
||||
@@ -751,11 +759,14 @@ struct CardFaceView: View, Equatable {
|
||||
/// **The card context menu, redesigned** (2026-08-09 ▸ "redesign context menu for cards", card
|
||||
/// fe66c461): four groups, a divider between each, the owner's shape verbatim —
|
||||
///
|
||||
/// 1. Open, Copy Link, Rename, Style ▸ (Symbol, Color)
|
||||
/// 2. Copy, Cut, Paste, Paste Special ▸ (Paste Image into Card, … more tbd)
|
||||
/// 1. Open, Rename, Style ▸ (Symbol, Color), Labels ▸ (up to 12 ranked toggles, More…)
|
||||
/// 2. Copy, Cut, Paste, Copy Special ▸ (Copy Link), Paste Special ▸ (Paste Image into Card, … more tbd)
|
||||
/// 3. Navigation ▸ (Move Left, Move Right)
|
||||
/// 4. Send to Trash
|
||||
///
|
||||
/// **Group 1 gained Labels and group 2 gained Copy Special on 2026-08-09** (card 28c79ffe) — see
|
||||
/// `labelsMenu` for the submenu, and "Copy Link moves" below for the relocation.
|
||||
///
|
||||
/// Every row below routes through an *existing* write primitive it twins — nothing here invents a
|
||||
/// new way to touch disk, only new arrangements and, for one group, a new targeting rule over ones
|
||||
/// the app already has (`OpenCardCommand`, `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`,
|
||||
@@ -767,10 +778,21 @@ struct CardFaceView: View, Equatable {
|
||||
///
|
||||
/// ### Two deviations from the card's literal list, both kept and both journaled
|
||||
///
|
||||
/// **Copy Link stays.** The owner's list does not mention it, but it shipped the same day this
|
||||
/// card was filed (design ruling 2026-08-09, card 737a949f) and nothing here was asked to retire
|
||||
/// it — dropping a same-day feature would be a behavior change, not a structure one. It rides in
|
||||
/// the first group, immediately after Open (its original neighbor), ahead of Rename.
|
||||
/// **Copy Link stays** — and, since 2026-08-09, has a home the owner named. It shipped the same
|
||||
/// day the menu redesign was filed (design ruling, card 737a949f), so that card's list did not
|
||||
/// mention it and it was parked in group 1 after Open as a deviation kept-and-journaled. The
|
||||
/// owner's next layout resolves it: **Copy Link now sits in a new `Copy Special` submenu**, beside
|
||||
/// the `Paste Special` it mirrors, and their own comment on card 28c79ffe says so in as many words
|
||||
/// ("note 'copy link' has been moved to 'copy special'"). Nothing about the row itself changed —
|
||||
/// same action, same `selectedCount == 1` enablement, same "a link is singular" rule.
|
||||
///
|
||||
/// **It stays in `boardActions` even so** (the VoiceOver custom-action list), which is a deliberate
|
||||
/// exception to that list's own "submenus are absent" rule. The rule is about *containers* — Style,
|
||||
/// Navigation and Paste Special are doors onto other surfaces, and a flat action list has nothing
|
||||
/// to say about a door. Copy Link is an action that merely changed which door draws it, and
|
||||
/// dropping it from the list would cost VoiceOver a real capability in exchange for a symmetry the
|
||||
/// list does not owe. (Paste Image into Card is absent for the older reason: it never was in the
|
||||
/// list.)
|
||||
///
|
||||
/// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more
|
||||
/// accurate one for what this button does on the board side — it is the staged move into
|
||||
@@ -897,8 +919,6 @@ struct CardFaceView: View, Equatable {
|
||||
Button("Open") {
|
||||
openCard(card.id)
|
||||
}
|
||||
Button("Copy Link") { copyLink() }
|
||||
.disabled(!copyLinkEnabled)
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Menu("Style") {
|
||||
@@ -911,16 +931,24 @@ struct CardFaceView: View, Equatable {
|
||||
// Both rows share this exact condition, so the submenu itself greys out with them rather than
|
||||
// opening to reveal two disabled rows.
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
labelsMenu
|
||||
|
||||
Divider()
|
||||
|
||||
// Group 2 — Copy, Cut, Paste, Paste Special ▸ Paste Image into Card.
|
||||
// Group 2 — Copy, Cut, Paste, Copy Special ▸ Copy Link, Paste Special ▸ Paste Image into Card.
|
||||
Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
|
||||
.disabled(!copyEnabled)
|
||||
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Paste") { appModel.clipboard.paste(into: store) }
|
||||
.disabled(!pasteEnabled)
|
||||
Menu("Copy Special") {
|
||||
Button("Copy Link") { copyLink() }
|
||||
.disabled(!copyLinkEnabled)
|
||||
}
|
||||
// Its one row's condition, so the submenu greys out with it rather than opening onto a
|
||||
// disabled row — `Style`'s rule, one group down.
|
||||
.disabled(!copyLinkEnabled)
|
||||
Menu("Paste Special") {
|
||||
Button("Paste Image into Card") {
|
||||
appModel.clipboard.pasteImage(intoCard: card.id, in: store)
|
||||
@@ -952,12 +980,109 @@ struct CardFaceView: View, Equatable {
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// **Group 1's fourth row: `Labels`** (2026-08-09, Pipeline card 28c79ffe; `FrontmatterKeys.labels`,
|
||||
/// whose reservation the owner retired the same day) — the owner's spec verbatim: "a list of up to
|
||||
/// 12 most frequently and recently used labels", a separator, then "More… (show a dialog with a
|
||||
/// list of all used labels + ability to create new)".
|
||||
///
|
||||
/// ### The twelve, and where they come from
|
||||
///
|
||||
/// `LabelRanking.ranked` — frequency across the board's live and trashed cards, tie-broken by an
|
||||
/// app-side MRU, then alphabetically for a total order. That function's own doc comment states the
|
||||
/// arithmetic and the one consequence worth flagging (a brand-new label does not jump a full menu).
|
||||
///
|
||||
/// **Toggles, not buttons**, so the row says what the card already is: a `Toggle` renders as a
|
||||
/// checked row in an AppKit menu, which is exactly the "toggling membership for the clicked card"
|
||||
/// the spec asks for made visible. The read is free — `card` is a compared parameter this view
|
||||
/// already has, so a checkmark costs no lookup at all.
|
||||
///
|
||||
/// ### One card, and the checkmark is why
|
||||
///
|
||||
/// Every other item-shaped row in this menu widens to the selection when the clicked card is a
|
||||
/// member of it (`targetIDs` — Copy, Cut, Style, Send to Trash). **These rows deliberately do
|
||||
/// not**, for two reasons that point the same way:
|
||||
///
|
||||
/// - **A checkmark is a claim about one card.** A three-card selection where two carry `bug` has no
|
||||
/// honest checked state, and an AppKit menu item has no mixed one to draw. The widening rows all
|
||||
/// carry no state — they say what they will *do*, never what is already true — which is exactly
|
||||
/// why widening costs them nothing and would cost this everything.
|
||||
/// - **It keeps the menu render-safe.** `targetIDs` reads `store.selection`, and `.contextMenu`'s
|
||||
/// builder is not lazy (this struct's top-of-file note; `copyLinkEnabled`'s doc comment): the
|
||||
/// checkmark is computed *while the menu is built*, not inside an action, so a widened one would
|
||||
/// subscribe every card face's body to board-wide selection state — the O(board) regression
|
||||
/// `BoardRenderPerformanceTests.selectionStillRepaints` exists to catch.
|
||||
///
|
||||
/// Flagged for owner review: if tagging a multi-card selection at once is wanted, it is a
|
||||
/// *different* control — a row that reads "Add ⟨label⟩ to 3 Cards", not a checkmark.
|
||||
///
|
||||
/// ### What this builder is allowed to read, and what it costs
|
||||
///
|
||||
/// Two Observation reads, both narrow and both rarely-changing — the class this menu's `.disabled`
|
||||
/// modifiers already draw from:
|
||||
///
|
||||
/// - `store.labelIndex`, the board's used-labels universe. The O(board) walk behind it happens
|
||||
/// **once per applied snapshot**, on the store, and the assignment is equality-gated so the
|
||||
/// property changes only when the board's labels genuinely change (its own doc comment says why
|
||||
/// that gate is load-bearing rather than an optimisation).
|
||||
/// - `appModel.labelRecents.labels`, the MRU. `appModel.styleRecents` is read in this very body
|
||||
/// already (the `==` gate's own note lists it), and this list moves on exactly the same cadence:
|
||||
/// once per label the user applies, which is a deliberate gesture and not a marquee sample.
|
||||
///
|
||||
/// What is left in the builder is `LabelRanking.ranked`'s sort, whose size is the board's **label
|
||||
/// vocabulary** — tens of entries — and not its card count. That is the whole of "no O(board) work
|
||||
/// in the menu builder": the board-sized part is cached, and the part that runs here is bounded by
|
||||
/// how many distinct labels exist.
|
||||
///
|
||||
/// ### More…
|
||||
///
|
||||
/// Opens `LabelPickerPopover` through a session in `TransientBoardState`, `Style…`'s exact
|
||||
/// mechanism and for its reasons — see that view for why a popover rather than a sheet.
|
||||
///
|
||||
/// **The submenu never greys out whole.** Even a read-only board can open More… to *look* at the
|
||||
/// board's vocabulary (Reveal in Finder's posture: inspection is not a mutation), so the lock
|
||||
/// disables the twelve toggles and the dialog's own controls rather than the door to them.
|
||||
@ViewBuilder
|
||||
private var labelsMenu: some View {
|
||||
Menu("Labels") {
|
||||
ForEach(rankedLabels, id: \.self) { name in
|
||||
Toggle(name, isOn: labelBinding(name))
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
if !rankedLabels.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
Button("More…") { store.transient.beginLabelEditor(forCard: card.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The twelve, ranked — see `labelsMenu` for the cost argument behind these two reads.
|
||||
private var rankedLabels: [String] {
|
||||
LabelRanking.ranked(store.labelIndex, recents: appModel.labelRecents.labels)
|
||||
}
|
||||
|
||||
/// One toggle row's state: whether **this** card carries the label, and the write that flips it.
|
||||
///
|
||||
/// The read is off `card.labels` — the value this face was handed — so it costs no store lookup and
|
||||
/// no selection read. The write goes through the one funnel every label surface shares, so the
|
||||
/// menu, the More… dialog and the card window's sidebar cannot come to mean three different things
|
||||
/// (`LabelCommand`).
|
||||
private func labelBinding(_ name: String) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { CardLabels.contains(name, in: card.labels.value ?? []) },
|
||||
set: { _ in
|
||||
LabelCommand.toggle(name, onCard: card.id, in: store, recents: appModel.labelRecents)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions — every one calling the
|
||||
/// *same* private method its menu row does, so the two surfaces cannot come to mean different
|
||||
/// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style…
|
||||
/// note): each opens its own accessible surface — a popover, or the submenu itself, which the
|
||||
/// context menu already exposes reachably (VO-⇧-M) — and is "not an action" in the flat sense this
|
||||
/// list carries.
|
||||
/// list carries. **Copy Link is the one exception**, and it moved into a submenu on 2026-08-09
|
||||
/// without leaving here — see `boardMenu`'s "Copy Link stays" note for why. Labels is absent like
|
||||
/// its fellow submenus: it is a container, and its own rows are checkmarks rather than actions.
|
||||
@ViewBuilder
|
||||
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
||||
Button("Open") { openCard(card.id) }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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<Bool> {
|
||||
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<Bool> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user