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:
+113
-10
@@ -109,12 +109,46 @@ struct LaneView: View {
|
||||
// the strip's logic, external Finder file sessions against those same zones — because
|
||||
// single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||
// **The lane is an accessibility container** — "window → lanes (accessibility containers, in
|
||||
// lane `order`) → cards (leaf elements, in card `order`)" (10-accessibility.md ▸ The board
|
||||
// through VoiceOver). `.contain` rather than `.combine`: the header, the new-card button and
|
||||
// every card must stay individually reachable, which is the whole point of a container the
|
||||
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
|
||||
.accessibilityElement(children: .contain)
|
||||
// "⟨title⟩, lane, N cards", where **N is the rendered count and therefore the filter's** —
|
||||
// the very collection the visible badge counts, so the spoken count and the drawn one are
|
||||
// one number ("the count reads the search filter like the visible badge"). A card the query
|
||||
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
|
||||
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
|
||||
// by construction rather than by a second rule.
|
||||
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
headerContent
|
||||
// **The lane title is a heading** — "lane titles are headings, so the headings rotor
|
||||
// jumps lane-to-lane; on a one-dimensional board that *is* structural navigation"
|
||||
// (10-accessibility.md ▸ Rotor). One flattened element rather than icon + text + badge:
|
||||
// the glyph and the count are the container's information, already spoken by its label,
|
||||
// and three stops where the design asks for a heading would make the rotor useless.
|
||||
//
|
||||
// `.contain` while a rename is open, because the flattening would otherwise swallow the
|
||||
// text field the user is typing into — the one moment this subtree holds a control
|
||||
// rather than chrome.
|
||||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||||
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
|
||||
.accessibilityAddTraits(headerTraits)
|
||||
// **VO-Space toggles the lane's selection** — the ⌘-click analogue 10-accessibility.md
|
||||
// gives a card, applied to the other selectable thing on the board, and routed through
|
||||
// the same `BoardStore.click` funnel the pointer uses so the homogeneity and
|
||||
// container rules are `SelectionGrammar`'s single answer rather than a second one.
|
||||
// Deliberately **not** the header's own plain-click semantics: "moving the VO cursor
|
||||
// never mutates selection … VO-Space on a card toggles its selection (the ⌘-click
|
||||
// analogue — a toggle, never plain click's replace)", and a VO-Space that replaced would
|
||||
// silently wipe a multi-lane selection the user had just built.
|
||||
.accessibilityAction { toggleLaneSelection() }
|
||||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||||
// including the empty stretch between the badge and the button.
|
||||
.contentShape(Rectangle())
|
||||
@@ -143,6 +177,14 @@ struct LaneView: View {
|
||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
.contextMenu { laneMenu }
|
||||
// **The context menu's plain 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). The menu itself
|
||||
// stays the inventory and is reachable the standard way (VO-⇧-M); this is the same four
|
||||
// commands one rotor turn closer. Style… is deliberately absent: it opens a popover —
|
||||
// its own accessible surface — and the quick-style swatch `Picker` beside it is not an
|
||||
// action at all.
|
||||
.accessibilityActions { laneActions }
|
||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||
// lane's own furniture — `styleEditorPresentation` decides whether this lane is the
|
||||
// session's presenting anchor at all.
|
||||
@@ -181,10 +223,8 @@ struct LaneView: View {
|
||||
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
|
||||
// this lane 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 lane outright.
|
||||
Button("Rename") {
|
||||
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -199,10 +239,52 @@ struct LaneView: View {
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
|
||||
// Style… above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) — 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)
|
||||
}
|
||||
|
||||
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
|
||||
/// call site). Every one of them calls the *same* private method its menu row does, so the two
|
||||
/// surfaces cannot drift into meaning different things — which is the only way "not a separate
|
||||
/// design surface" is checkable rather than merely intended.
|
||||
@ViewBuilder
|
||||
private var laneActions: some View {
|
||||
let units = LaneLayoutMath.displayUnits(of: lane)
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
|
||||
.disabled(!store.acceptsBoardMutations || units <= 1)
|
||||
Button("Delete") { deleteTargets() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
|
||||
/// (the context menu row and its accessibility twin).
|
||||
private func beginRename() {
|
||||
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
|
||||
}
|
||||
|
||||
/// File ▸ Delete's store path over the context-menu target set — the menu row's body and its
|
||||
/// accessibility twin's alike.
|
||||
private func deleteTargets() {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
|
||||
/// VO-Space's landing: the ⌘-click funnel, on this lane. `togglesOnRepeat` stays false because
|
||||
/// only the *plain* branch reads it — the ⌘ branch is already a toggle, which is the point.
|
||||
private func toggleLaneSelection() {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: .command
|
||||
)
|
||||
}
|
||||
|
||||
/// The header element's traits: a heading always, and **selected when the lane is** — "state is
|
||||
/// never colour-alone: selection is a ring plus trait" (10-accessibility.md).
|
||||
private var headerTraits: AccessibilityTraits {
|
||||
isSelected ? [.isHeader, .isSelected] : [.isHeader]
|
||||
}
|
||||
|
||||
/// The width stepper — "the header context menu's Width control (stepper, uncapped) is the
|
||||
@@ -330,7 +412,10 @@ struct LaneView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
|
||||
// "The lane header's new-card button is a labeled child ('New card in ⟨lane⟩')"
|
||||
// (10-accessibility.md ▸ The board through VoiceOver) — the header's one child element, which
|
||||
// is why it lives in an overlay outside the flattened bar rather than inside it.
|
||||
.accessibilityLabel(AccessibilityPhrases.newCardLabel(lane: lane.title.value))
|
||||
// Mutating, so the read-only lock disables it like every other write path
|
||||
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
|
||||
// inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item.
|
||||
@@ -466,7 +551,7 @@ struct LaneView: View {
|
||||
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
||||
// `units` columns of `standard` (03-board-ui.md § Layout — full visibility).
|
||||
MasonryLayout(columns: columns, spacing: cardSpacing) {
|
||||
ForEach(slots) { slot in
|
||||
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .card(card):
|
||||
@@ -502,6 +587,24 @@ struct LaneView: View {
|
||||
// holds identically under Reduce Motion: a transition that does not fire has no
|
||||
// variant to choose between.
|
||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||
// **VoiceOver reads the masonry by `order`, not by column** — 10-accessibility.md
|
||||
// ▸ Logical order, not masonry position (decided): "within a wide lane,
|
||||
// VoiceOver reads cards by `order` — the interior grid columns are presentation
|
||||
// only. This deliberately diverges from on-screen geometry."
|
||||
//
|
||||
// The divergence is real and it is why an explicit priority is needed at all:
|
||||
// `MasonryLayout` assigns child `i` to column `i % columns`, so in a 3-unit lane
|
||||
// the second card by `order` is drawn to the *right* of the first, not below it
|
||||
// — and an accessibility tree sorted by geometry (which is what a container does
|
||||
// without this) would read the board column-major: 1, 4, 7, 2, 5, 8 …, an order
|
||||
// that exists nowhere in the model, on disk, or in the keyboard grammar.
|
||||
// Priority descends with the slot index, so the highest reads first and the list
|
||||
// is exactly `slots` — the same sequence the masonry is handed and the same one
|
||||
// `SelectionGrammar` flattens.
|
||||
//
|
||||
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
|
||||
// a slot that is not an element consumes no priority.
|
||||
.accessibilitySortPriority(Double(slots.count - index))
|
||||
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
|
||||
// resolves against an explicit `.id`, and it goes outermost so the transition
|
||||
// above stays inside the identified view rather than around it.
|
||||
|
||||
Reference in New Issue
Block a user