Realign code with the 2026-07-29 accessibility rulings
Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29): - The board-change digest covers the trash while View > Show Trash is on: BoardDiff.between gains includingTrash, keying its card index by ItemPath so foreign purges, restores, and Empty Trash join the digest; crossings of the trash boundary still read deleted/restored, never moved, on both sides of the toggle. BoardStore.land passes the store's own isTrashVisible - no new injection seam. - A vanished head with surviving co-selection is still named: naming and recovery are independent axes, so BoardAnnouncer's vanished-focus rung fires on all branches while the survivors-veto now gates only the recovery half (recovery implies vanished, no longer both-or-neither). - Banner-row buttons are literal FKA Tab stops: BannerRow.controls is the row's testable button inventory, BannerRowView renders from it with .focusable() on each button; the combined VoiceOver element stays unconditional - custom actions and Tab stops are independent surfaces. 19 tests added, 2 expectations updated to the rulings. 1607 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -154,7 +154,12 @@ private struct BannerRowView: View {
|
||||
.background(row.tone.fill)
|
||||
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
||||
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
|
||||
// buttons survive as custom actions of the combined element rather than as separate stops.
|
||||
// buttons stay inside the combined element, where VoiceOver surfaces them as the row's
|
||||
// custom actions — **and are Tab stops in their own right besides** (see `trailingControls`:
|
||||
// 10-accessibility.md ▸ Full Keyboard Access, ruled 2026-07-29). The two are independent
|
||||
// surfaces, so the combine is unconditional: nothing here is uncombined under FKA, because
|
||||
// the focus loop does not need the AX tree's permission to include a button and a VoiceOver
|
||||
// user would otherwise hear a different row depending on a keyboard setting.
|
||||
//
|
||||
// The label is composed by `AccessibilityPhrases` rather than spelled here because the two
|
||||
// standing conditions are also *announced* on arrival and clearance (10-accessibility.md ▸
|
||||
@@ -180,26 +185,49 @@ private struct BannerRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's buttons, rendered straight from `BannerRow.controls` — **the Tab loop the FKA
|
||||
/// ruling asks for** (10-accessibility.md ▸ Full Keyboard Access, 2026-07-29: "'Every control'
|
||||
/// is literal and includes banner-row buttons … FKA serves sighted keyboard-only users, to whom
|
||||
/// VO custom actions are invisible, and Cancel on an in-progress operation is exactly the
|
||||
/// control that cannot require a pointer").
|
||||
///
|
||||
/// Driven from the row's inventory rather than from a pair of `if`s over the same data, so what
|
||||
/// a row's controls *are* is one testable fact (`BannerCenterTests`) instead of a view detail
|
||||
/// that a headless suite cannot see.
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if case let .inProgress(operation) = row, let cancel = operation.cancel {
|
||||
ForEach(row.controls) { control in
|
||||
button(for: control)
|
||||
// **A literal tab stop**, and the focus ring left on to show it — the style editor's
|
||||
// wells' rule, for its reason: these are controls, not window furniture, so unlike
|
||||
// the board strip (`BoardView`) there is nothing here to suppress. Stated on the
|
||||
// button rather than left to the button style, because `.plain` and `.link` render
|
||||
// as bare content and the combine above puts them inside another accessibility
|
||||
// element: the focus item is asked for outright so neither can quietly cost the row
|
||||
// its keyboard reach.
|
||||
.focusable()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func button(for control: BannerRowControl) -> some View {
|
||||
switch control {
|
||||
case let .cancel(cancel):
|
||||
// Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
|
||||
// nothing lost". Git brackets pass no closure and therefore get no button.
|
||||
Button("Cancel", action: cancel)
|
||||
Button(control.label, action: cancel)
|
||||
.buttonStyle(.link)
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
if let dismissID = row.dismissID {
|
||||
case let .dismiss(id):
|
||||
Button {
|
||||
onDismiss(dismissID)
|
||||
onDismiss(id)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Dismiss")
|
||||
.help("Dismiss")
|
||||
.accessibilityLabel(control.label)
|
||||
.help(control.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user