Build the VoiceOver tree and actions
The board window's accessibility tree per DESIGN/10: lanes are containers labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the badge's own collection); cards are one flattened element each — label = title or the untitled placeholder, value = attachment count + "cut, pending paste", selection via trait; face icon, stripe, and paperclip are decorative and hidden. Masonry never leaks into traversal: slots carry order-keyed accessibilitySortPriority, so a wide lane reads by card order, not column-major. Lane titles carry the heading trait for the rotor. VO-Space is the ⌘-click analogue routed through the existing BoardStore.click funnel (SelectionGrammar stays the single answer for toggle and container-boundary rules) — cards and lane headers both. Context-menu rows double as custom accessibility actions, each calling the same private method as its menu row so the surfaces cannot drift; trash cards expose Delete and Reveal in Finder and never Open. The trash column is pinned last via sort priority 0, its label/value re-routed through the new AccessibilityPhrases seam; toggling trash visibility posts a one-line announcement from the store seam (both command faces). The invisible lane-resize drag strip leaves the tree — the stepper and menu items are the accessible width path. AccessibilityPhrases is the pure vocabulary seam (labels, values, plural folding shared with TrashModel.phrase), pinned by its own test suite. Both schemes build; 1466 unit tests green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -163,12 +163,24 @@ struct CardFaceView: View {
|
||||
openCard(card.id)
|
||||
})
|
||||
.contextMenu { boardMenu(openCard: openCard) }
|
||||
// **The menu's rows, additionally as custom actions** — "where SwiftUI additionally
|
||||
// surfaces menu items as custom accessibility actions, that's free improvement, not
|
||||
// a separate design surface" (10-accessibility.md ▸ Actions come from the context
|
||||
// menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M).
|
||||
// Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style
|
||||
// swatch `Picker` beside it is not an action.
|
||||
.accessibilityActions { boardActions(openCard: openCard) }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
case let .trash(confirmations):
|
||||
face
|
||||
.contextMenu { trashMenu(confirmations: confirmations) }
|
||||
// The trash's two rows and **no third** — "there is no Open"
|
||||
// (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
|
||||
// absence is structural on this side too: `openCard` is the board case's payload, so
|
||||
// there is nothing here an Open action could even call.
|
||||
.accessibilityActions { trashActions(confirmations: confirmations) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +216,33 @@ struct CardFaceView: View {
|
||||
// has anything to act on there — `LaneView.renderedCards`.)
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
.contentShape(Rectangle())
|
||||
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
||||
// VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
|
||||
// count when present, selected state via trait. Face icon and chips are decorative — folded
|
||||
// into the element, never separately focusable". So the icon, the accent stripe and the
|
||||
// paperclip contribute nothing of their own — the count they stood for rides the value below.
|
||||
//
|
||||
// `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
|
||||
// would swallow the text field the user is typing into. Board-only by construction, since
|
||||
// `isRenaming` is (`CardFaceRole`).
|
||||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||||
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
||||
// The attachment count, the deferred cut's "cut, pending paste", or both — and the empty
|
||||
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
|
||||
// it is not a conditional modifier).
|
||||
.accessibilityValue(AccessibilityPhrases.cardValue(
|
||||
attachments: card.attachments.count,
|
||||
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
||||
))
|
||||
// "Selection state is always readable from the element (trait)" — the other half of "state
|
||||
// is never colour-alone", whose visible half is the accent stroke above.
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
// **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates
|
||||
// selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle,
|
||||
// never plain click's replace)". Routed through the same `BoardStore.click` funnel the
|
||||
// pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are
|
||||
// `SelectionGrammar`'s single answer rather than a second one written here.
|
||||
.accessibilityAction { toggleSelection() }
|
||||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||||
// two-stage Finder rename): one click selects and that is all it does — no timer, no
|
||||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||||
@@ -412,10 +451,8 @@ struct CardFaceView: View {
|
||||
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
|
||||
// this card to be the *sole* selection; a context menu already names its target by where it
|
||||
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
|
||||
Button("Rename") {
|
||||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
|
||||
@@ -424,10 +461,19 @@ struct CardFaceView: View {
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
|
||||
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
|
||||
// so this row gets it for free.
|
||||
Button("Delete") {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Delete") { deleteTargets() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// `boardMenu`'s plain 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.
|
||||
@ViewBuilder
|
||||
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
||||
Button("Open") { openCard(card.id) }
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Delete") { deleteTargets() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||
@@ -443,16 +489,51 @@ struct CardFaceView: View {
|
||||
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
|
||||
@ViewBuilder
|
||||
private func trashMenu(confirmations: TrashConfirmations) -> some View {
|
||||
Button("Delete") {
|
||||
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Delete") { requestPurge(confirmations) }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
Button("Reveal in Finder") { revealInFinder() }
|
||||
}
|
||||
|
||||
/// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open.
|
||||
@ViewBuilder
|
||||
private func trashActions(confirmations: TrashConfirmations) -> some View {
|
||||
Button("Delete") { requestPurge(confirmations) }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Reveal in Finder") { revealInFinder() }
|
||||
}
|
||||
|
||||
// MARK: - The rows' bodies
|
||||
|
||||
/// Board ▸ Rename's store path, seeded with the card's live title.
|
||||
private func beginRename() {
|
||||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||||
}
|
||||
|
||||
/// File ▸ Delete's store path over the context-menu target set.
|
||||
private func deleteTargets() {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
|
||||
/// The trash's **permanent** delete, through the window's confirmation host — never straight to
|
||||
/// the store, because the alert is what stands between this row and an unrecoverable loss.
|
||||
private func requestPurge(_ confirmations: TrashConfirmations) {
|
||||
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
||||
}
|
||||
|
||||
private func revealInFinder() {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
|
||||
/// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
|
||||
/// trash card's toggle can no more mix with a board selection than a ⌘-click could.
|
||||
private func toggleSelection() {
|
||||
store.click(
|
||||
SelectionTarget(id: card.id, kind: .card, container: role.container),
|
||||
modifier: .command
|
||||
)
|
||||
}
|
||||
|
||||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||||
@@ -536,14 +617,20 @@ struct CardFaceView: View {
|
||||
|
||||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||||
/// accessibility *value* rather than onto the face: it is useful to know, not to look at.
|
||||
///
|
||||
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
|
||||
/// decorative — folded into the element, never separately focusable … the flattened element
|
||||
/// carries the attachment count in its value". The flattening above would drop a label here
|
||||
/// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
|
||||
/// outside the flattened face.
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user