# Accessibility Code Patterns

Back to [SKILL.md](../SKILL.md).

## Contents

- [Labels, values, hints](#labels-values-hints)
- [Traits and grouping](#traits-and-grouping)
- [Custom and adjustable controls](#custom-and-adjustable-controls)
- [Focus patterns](#focus-patterns)
- [Dynamic Type and layout](#dynamic-type-and-layout)
- [Custom rotors](#custom-rotors)
- [System preference values](#system-preference-values)
- [UIKit](#uikit)
- [AppKit](#appkit)
- [Mistakes to catch in review](#mistakes-to-catch-in-review)
- [Voice Control](#voice-control)
- [Switch Control](#switch-control)
- [Full Keyboard Access](#full-keyboard-access)
- [Automated checks with XCTest](#automated-checks-with-xctest)

## Labels, values, hints

```swift
// Icon-only button: the label is what VoiceOver and Voice Control use.
Button { bookmark(article) } label: {
    Image(systemName: article.isSaved ? "bookmark.fill" : "bookmark")
}
.accessibilityLabel("Bookmark")

// Value: speak the number people care about, not the raw slider position.
Slider(value: $brightness)                  // 0.0 ... 1.0
    .accessibilityLabel("Brightness")
    .accessibilityValue("\(Int(brightness * 100)) percent")

// Hint: what happens when activated, read last and only after a pause.
Button("Archive", action: archiveThread)
    .accessibilityHint("Moves this conversation out of the inbox")
```

## Traits and grouping

`.accessibilityAddTraits` adds to the element's existing traits rather than
replacing them:

```swift
Text(departureCountdown)
    .accessibilityAddTraits(.updatesFrequently)
```

Merge a row so it is one stop instead of three:

```swift
HStack {
    Image(driver.photoName)
    VStack(alignment: .leading) {
        Text(driver.name)
        Text(driver.vehicle)
    }
}
.accessibilityElement(children: .combine)
```

### Two-state custom control

First choice is a real `Toggle`. When the design forces a custom view, expose
it as a toggle yourself:

```swift
struct PinChip: View {
    let noteTitle: String
    @Binding var isPinned: Bool

    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: isPinned ? "pin.fill" : "pin")
            Text(noteTitle)
        }
        .onTapGesture { isPinned.toggle() }
        .accessibilityElement()
        .accessibilityLabel("Pin \(noteTitle)")
        .accessibilityValue(isPinned ? "On" : "Off")
        .accessibilityAddTraits(.isToggle)          // iOS 17+
        .accessibilityAction { isPinned.toggle() }  // default activate action
    }
}
```

## Custom and adjustable controls

For quantities, steppers, ratings, sliders and any other adjustable value,
start from the native control (`Stepper`, `Slider`, `Picker`). Only a truly
custom control needs the work below: in SwiftUI
`.accessibilityAdjustableAction`, in UIKit
`accessibilityTraits.insert(.adjustable)` plus `accessibilityIncrement()` /
`accessibilityDecrement()`.

```swift
struct SpiceLevelPicker: View {
    @Binding var level: Int          // 1...4
    private let range = 1...4

    var body: some View {
        HStack {
            ForEach(range, id: \.self) { value in
                Image(systemName: value <= level ? "flame.fill" : "flame")
                    .onTapGesture { level = value }
            }
        }
        .accessibilityElement()
        .accessibilityLabel("Spice level")
        .accessibilityValue("\(level) of \(range.upperBound)")
        .accessibilityAdjustableAction { change in
            switch change {
            case .increment: level = min(level + 1, range.upperBound)
            case .decrement: level = max(level - 1, range.lowerBound)
            @unknown default: break
            }
        }
    }
}
```

## Focus patterns

Returning focus after a sheet, with a `Bool`:

```swift
struct InvoiceHeader: View {
    @State private var editing = false
    @AccessibilityFocusState private var editButtonFocused: Bool

    var body: some View {
        Button("Edit invoice", systemImage: "pencil") { editing = true }
            .accessibilityFocused($editButtonFocused)
            .sheet(isPresented: $editing) {
                InvoiceEditor()
                    .onDisappear {
                        Task { @MainActor in
                            try? await Task.sleep(nanoseconds: 100_000_000)
                            editButtonFocused = true
                        }
                    }
            }
    }
}
```

Several targets, with an optional enum:

```swift
enum AddressFocus: Hashable { case street, city, postcode }

@AccessibilityFocusState private var addressFocus: AddressFocus?
// TextField("Street", text: $street).accessibilityFocused($addressFocus, equals: .street)
// On a bad postcode: addressFocus = .postcode
```

## Dynamic Type and layout

```swift
struct TransferRow: View {
    @Environment(\.dynamicTypeSize) private var typeSize
    @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 44
    @ScaledMetric(relativeTo: .body) private var gap: CGFloat = 12
    @ScaledMetric(relativeTo: .title) private var glyphSize: CGFloat = 24

    let transfer: Transfer

    var body: some View {
        let layout = typeSize.isAccessibilitySize
            ? AnyLayout(VStackLayout(alignment: .leading, spacing: gap))
            : AnyLayout(HStackLayout(spacing: gap))

        layout {
            Image(systemName: "arrow.up.arrow.down")
                .font(.system(size: glyphSize))
            Text(transfer.counterparty).font(.body)
            Text(transfer.amountText).font(.headline)
        }
        .frame(minHeight: rowHeight)
    }
}
```

- `@ScaledMetric(relativeTo:)` is for custom hit regions, control heights,
  spacing and glyph sizes that should keep pace with the text.
- `dynamicTypeSize.isAccessibilitySize` is the usual switch point from a
  horizontal row to a leading-aligned vertical stack.

## Custom rotors

```swift
List(inbox) { item in
    MessageRow(message: item)
}
.accessibilityRotor("Flagged") {
    ForEach(inbox.filter(\.isFlagged)) { item in
        AccessibilityRotorEntry(item.subject, id: item.id)
    }
}
```

## System preference values

The four environment values to read are `\.accessibilityReduceMotion`,
`\.accessibilityReduceTransparency`, `\.colorSchemeContrast` and
`\.legibilityWeight`. Usage is in [SKILL.md](../SKILL.md#system-preferences).

## UIKit

```swift
final class StatusPill: UIView {
    func configureAccessibility(title: String) {
        isAccessibilityElement = true
        accessibilityLabel = title
        accessibilityTraits.insert(.button)
        accessibilityTraits.remove(.staticText)
    }
}

// Status, partial update, full screen change:
UIAccessibility.post(notification: .announcement, argument: "3 files uploaded")
UIAccessibility.post(notification: .layoutChanged, argument: resultsTable)
UIAccessibility.post(notification: .screenChanged, argument: onboardingTitle)
```

## AppKit

Everything flows through `NSAccessibilityProtocol`. Customize only the pieces
AppKit gets wrong or leaves out.

### Custom view: role, label, value, action

```swift
final class LockToggleView: NSView {
    private(set) var isLocked = false {
        didSet {
            NSAccessibility.post(element: self, notification: .valueChanged)
            needsDisplay = true
        }
    }

    override func accessibilityLabel() -> String? { "Lock layer" }
    override func accessibilityValue() -> Any? { isLocked ? "Locked" : "Unlocked" }
    override func isAccessibilityElement() -> Bool { true }
    override func accessibilityPerformPress() -> Bool {
        isLocked.toggle()
        return true
    }
    override func accessibilityRole() -> NSAccessibility.Role? { .button }
}
```

### Elements with no view

For things that are drawn rather than backed by an `NSView`, such as one bar
in a chart or an annotation painted on a canvas, create an
`NSAccessibilityElement`:

```swift
let barElement = NSAccessibilityElement.element(
    withRole: .button,
    frame: barFrameInScreenCoordinates,
    label: "March revenue, 42 thousand",
    parent: chartView
)
```

### Notifications

```swift
// The accessible value changed.
NSAccessibility.post(element: sliderView, notification: .valueChanged)

// A passing status message.
NSAccessibility.post(
    element: exportPanelView,
    notification: .announcementRequested,
    userInfo: [.announcement: "Backup finished"]
)
```

Pick `.announcementRequested` for transient status and the specific
notification (such as `.valueChanged`) when the element's accessible value
actually changed.

## Mistakes to catch in review

- Tap targets below 44x44 pt.
- Traits assigned directly instead of through `.accessibilityAddTraits`.
- Hard-coded font sizes, so Dynamic Type has no effect.
- Focus not restored after a sheet closes.
- Icon-only buttons with no label.
- Increase Contrast, Reduce Transparency or Reduce Motion settings ignored.
- List rows left ungrouped, each producing several swipe stops.

## Voice Control

Voice Control builds its tap targets from accessibility labels, so every label
must be something a person can say, and unique among what is visible.
`accessibilityInputLabels` (iOS 14+) supplies shorter spoken alternatives when
the main label is long, clumsy, repeated, localized, acronym-heavy, or
normally shortened in speech.

```swift
Button("Start 45-minute progressive endurance ride") { startRide() }
    .accessibilityLabel("Start 45-minute progressive endurance ride")
    .accessibilityInputLabels(["Start ride", "Endurance ride", "Ride"])

NavigationLink(value: Route.notifications) {
    Label("Notification and alert preferences", systemImage: "bell.badge")
}
.accessibilityInputLabels(["Notifications", "Alerts"])
```

Also worth input labels: labels built from product names, playback controls,
steppers for quantities, and the same action repeated on every row.

### Speakable labels

```swift
// Bad: nothing to say.
Button("\u{1F5D1}") { remove(entry) }   // a wastebasket emoji

// Good: symbol on screen, words for speech.
Button { remove(entry) } label: { Image(systemName: "trash") }
    .accessibilityLabel("Delete")

// Bad: ten identical "Rename" buttons; Voice Control cannot tell them apart.
ForEach(playlists) { playlist in
    Button("Rename") { rename(playlist) }
}

// Good: keep the visible word, make the spoken name unique.
ForEach(playlists) { playlist in
    Button("Rename") { rename(playlist) }
        .accessibilityLabel("Rename \(playlist.name)")
}
```

## Switch Control

Switch Control scans element by element, so group to cut stops and add custom
actions for anything gesture-driven.

```swift
// Swipe actions also exposed as named actions.
TaskRow(task: task)
    .swipeActions {
        Button("Delete", role: .destructive) { delete(task) }
    }
    .accessibilityAction(named: "Mark done") { complete(task) }
    .accessibilityAction(named: "Delete") { delete(task) }
    .accessibilityAction(named: "Snooze until tomorrow") { snooze(task) }

// Context-menu operations reachable without a long press.
PhotoTile(photo: photo)
    .contextMenu {
        Button("Share") { share(photo) }
        Button("Add to Album") { addToAlbum(photo) }
        Button("Delete", role: .destructive) { delete(photo) }
    }
    .accessibilityAction(named: "Delete") { delete(photo) }
    .accessibilityAction(named: "Share") { share(photo) }
    .accessibilityAction(named: "Add to Album") { addToAlbum(photo) }
```

Grouping in numbers: this attachment row is five scan stops as written (icon,
name, date, size, chevron). With `.combine` it becomes one.

```swift
HStack {
    Image(systemName: "paperclip")
    Text(file.name)
    Text(file.modified, style: .date)
    Text(file.sizeText)
    Image(systemName: "chevron.right")
}
.accessibilityElement(children: .combine)
```

VoiceOver users benefit from the same change: one swipe per attachment instead
of five.

## Full Keyboard Access

The question a review asks: can someone using only a keyboard complete the
same common tasks as someone using touch? The mechanics (`UIFocusGuide`, tvOS
focus, arrow-key movement, focus sections, SwiftUI keyboard focus state,
`.focusable()`, cards that Tab skips, Tab order wiring) are `hig-inputs`
work.

Check:

- every interactive control can be reached from the keyboard;
- it activates with the keys people expect (Space, Return);
- the focus ring is visible and no custom style hides it;
- traversal is logical and never gets stuck in one region;
- gesture-only interactions have a keyboard alternative;
- the app's shortcuts do not shadow system shortcuts.

A custom control that Tab skips is filed as a keyboard-focus implementation
issue for `hig-inputs`, while the accessibility finding is recorded here.
Whatever the bug, write down what the current ordering and grouping do to the
four traversal paths: VoiceOver swipes, switch scanning, Voice Control
overlays and keyboard reach.

## Automated checks with XCTest

```swift
@MainActor
final class AccountScreenAccessibilityTests: XCTestCase {
    private func launchedApp() -> XCUIApplication {
        let app = XCUIApplication()
        app.launch()
        return app
    }

    func testControlsExposeLabels() {
        let app = launchedApp()
        let logout = app.buttons["Sign out"]
        XCTAssertTrue(logout.exists, "Sign out button should be exposed")
        XCTAssertTrue(logout.isEnabled, "Sign out button should be enabled")

        let firstRow = app.cells.element(boundBy: 0)
        XCTAssertTrue(firstRow.exists)
        XCTAssertTrue(firstRow.buttons["Remove device"].exists)
        XCTAssertFalse(firstRow.label.isEmpty, "Grouped row should read as one label")
    }

    func testTabMovesBetweenFields() {
        let app = launchedApp()
        let email = app.textFields["Email"]
        let password = app.secureTextFields["Password"]
        email.tap()
        XCTAssertTrue(email.hasFocus)
        email.typeText("\t")
        XCTAssertTrue(password.hasFocus)
    }

    func testRowKeepsItsIdentifier() {
        let app = launchedApp()
        let row = app.cells["device-row-primary"]
        XCTAssertTrue(row.exists)
        XCTAssertEqual(row.identifier, "device-row-primary")
    }
}
```

The identifier check matters because UI tests and custom-action tests target
rows by accessibility identifier.
