Over Grouped Control

Status: ready

Symptom

The fix for Label Soup went too far: the whole cart row became one element. The noise is gone, and so is direct access to the quantity stepper and the Remove button.

Jasmine tea, $14, quantity 2, actions available

Changing the quantity now means opening the Actions rotor, if the action was added at all.

Where it shows up

Product cards, cart rows, settings rows, media controls, transaction lists, checkout summaries, appointment cells.

Why it blocks the task

The team measured swipes, not task completion. Fewer stops looks like progress until someone tries to change the quantity.

Switch Control scans faster but the stepper isn't a target anymore. Voice Control can't find "Remove" because it's not a control on screen. For a primary action, a hidden custom action is a dead end for anyone who doesn't know the Actions rotor.

Root cause

Grouping was applied as a blanket fix.

  1. .combine or .ignore on the whole row.
  2. Interactive children became part of one parent element.
  3. A frequent action was moved into a custom action.
  4. The check counted stops instead of completing the task.

Before

CartRow(item)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(item.name)
    .accessibilityValue("Quantity \(item.quantity), \(item.price)")
    .accessibilityAction(named: "Increase quantity") { item.quantity += 1 }
    .accessibilityAction(named: "Remove") { remove(item) }

This isn't always wrong. It's wrong when changing the quantity is the main thing the user does on this screen, which in a cart it is.

Fix

Verdict: group the summary, keep the controls as controls.

Why: the user gets a short summary and direct access to the actions. Switch Control and Voice Control see real targets.

VStack(alignment: .leading) {
    ItemSummary(item)
        .accessibilityElement(children: .combine)

    Stepper("Quantity: \(item.quantity)", value: $item.quantity, in: 1...99)

    Button("Remove") { remove(item) }
        .accessibilityLabel("Remove \(item.name)")
        .accessibilityInputLabels(["Remove", "Remove \(item.name)"])
}

Three stops per row: summary, stepper, Remove. Before the over-grouping fix it was six or seven. After it, one with nothing reachable.

UIKit: the cell isn't an element itself, and the order is set explicitly. The summary is a UIAccessibilityElement with a frame:

cell.isAccessibilityElement = false

let summary = UIAccessibilityElement(accessibilityContainer: cell)
summary.accessibilityLabel = "Jasmine tea, $14"
summary.accessibilityFrameInContainerSpace = titleStack.frame

cell.accessibilityElements = [summary, quantityStepper, removeButton]

Check

Profile:   VoiceOver
Task:      change the quantity of jasmine tea to 3, then remove it
Expected:  the summary is one stop
           the stepper is the next stop, swipe up increases the quantity
           Remove names the item
           the Actions rotor isn't needed
Profile:   Switch Control
Task:      change the quantity
Expected:  the stepper is a scan target
           Remove is a scan target

WCAG mapping

  1. 2.1.1 Keyboard: every control is operable without a hidden gesture.
  2. 2.4.3 Focus Order: focus reaches every control the task needs.
  3. 4.1.2 Name, Role, Value: interactive children keep their role and state.
  1. Right Sized Grouping
  2. Accessible Card
  1. Chapter 5
  2. Chapter 10
  3. Chapter 11
  4. Chapter 17

Sources

  1. Apple Developer: accessibilityElement(children:)
  2. Apple Developer: accessibilityElements
  3. Apple Developer: UIAccessibilityElement