square / square/Listable

SwipeActionsView: action buttons exposed in accessibility tree while off-screen

Open Beginner friendly
#618 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Swift
Stars
210
Forks
35
Avg merge
1h 34m
Merged PRs (30d)
1

Description

Fix: Swipe-to-reveal action buttons exposed in accessibility tree while off-screen

Problem

When a Listable list item has swipe actions configured (leading or trailing), the DefaultSwipeActionButton instances are always present in the accessibility tree — even when the swipe is closed and the buttons are visually hidden (zero-width container). VoiceOver users navigate to invisible "Void", "Repeat", "Delete" buttons that they cannot activate because the buttons' activation points are beyond screen bounds.

How It Manifests

A VoiceOver user navigating a list hears:

"Bruschetta, $9.00" → "Void, button" → "Repeat, button" → "Next item..."

The "Void" and "Repeat" buttons are invisible — they're positioned off-screen at x = cellWidth (e.g., x=1196 on a 1180-wide screen). Attempting to activate them fails because the activation point is out of bounds. The user has no way to interact with these phantom elements.

Root Cause

SwipeActionsView positioning (SwipeActionsView.swift)

When the swipe state is .closed, the SwipeActionsView frame is set to zero width via bounds.divided(atDistance: 0, from: .maxXEdge).slice in ItemCell.ContentViewContainer.updateFrames(). The view is visually clipped, but its child DefaultSwipeActionButton instances remain as accessibility elements.

DefaultSwipeActionButton (line ~258 in ListableUI/Sources/Internal/SwipeActionsView.swift) is a UIButton subclass. UIButton is isAccessibilityElement = true by default. There is no code anywhere that hides these buttons from accessibility when the swipe is closed.

The correct accessibility path already exists

ItemCell.ContentViewContainer (in ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift) already sets accessibilityCustomActions on the container from the swipe actions (line ~325). These custom actions work correctly — they call performAccessibilityAction(_:) which triggers the swipe action's onTap handler and returns true. This is the proper VoiceOver interaction path.

So there are two competing accessibility paths:

  1. accessibilityCustomActions on the ContentViewContainer — works correctly, proper VoiceOver pattern
  2. ❌ Physical DefaultSwipeActionButton instances in the view hierarchy — always visible to VoiceOver even when off-screen, activation fails

Fix

Hide the SwipeActionsView from the accessibility tree so VoiceOver only uses the custom actions path. The simplest fix:

Option A: Hide SwipeActionsView from accessibility (recommended)

In SwipeActionsView.swift, set accessibilityElementsHidden = true on the SwipeActionsView in its initializer:

// In SwipeActionsView.init (around line 68)
override init(frame: CGRect) {
    // ... existing init code ...
    
    // Hide swipe action buttons from VoiceOver.
    // The ContentViewContainer already exposes these actions as
    // accessibilityCustomActions, which is the correct VoiceOver path.
    accessibilityElementsHidden = true
}

This permanently hides all child buttons from VoiceOver, relying entirely on the accessibilityCustomActions set by ContentViewContainer. This is the cleanest fix because:

  • The custom actions path already works correctly
  • The physical buttons are an implementation detail of the swipe gesture visual, not an accessibility interface
  • No state tracking needed (don't need to toggle based on open/closed state)
Option B: Toggle based on swipe state (more complex, probably unnecessary)

If there's a reason VoiceOver should be able to interact with the physical buttons when the swipe is open (e.g., if the custom actions path doesn't cover all cases), toggle accessibilityElementsHidden based on swipe state:

In SwipeActionsView.apply(state:) (line ~226):

func apply(state newState: SwipeActionState) {
    let priorState = state
    state = newState
    
    // Hide buttons from VoiceOver when swipe is closed
    accessibilityElementsHidden = (newState == .closed)
    
    // ... rest of existing code ...
}

And set the initial value in init:

accessibilityElementsHidden = true  // starts closed

Relevant Files

All paths are relative to the Listable repo root:

File What it contains
ListableUI/Sources/Internal/SwipeActionsView.swift SwipeActionsView class (line ~12) and DefaultSwipeActionButton class (line ~258). The buttons are created here and never hidden from accessibility. This is where the fix goes.
ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift ContentViewContainer manages swipe registration, positioning, and accessibility custom actions. Lines ~318-328 set up accessibilityCustomActions from swipe actions — this already works correctly and is the proper VoiceOver path.

How to Verify

  1. Existing tests should still pass — this change only affects accessibility tree visibility, not layout or behavior.

  2. Manual VoiceOver test:

    • Create a list with items that have trailing swipe actions
    • Navigate with VoiceOver through the list
    • Before fix: VoiceOver visits invisible swipe action buttons between list items
    • After fix: VoiceOver skips the physical buttons; swipe actions are available via the Actions rotor (custom actions) on the list item itself
  3. Verify custom actions still work:

    • Navigate to a list item with VoiceOver
    • Use the Actions rotor (swipe up/down)
    • The swipe action names (e.g., "Void", "Delete") should appear as custom actions
    • Activating them should trigger the action

Context

Found during accessibility fuzzing of a POS app that uses Listable. Affected screens include cart item rows (Void/Repeat), customer directory rows (Delete), and any other list using Listable's swipe actions. The phantom buttons confuse VoiceOver users who hear button announcements for elements they can't see or interact with.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in ListableUI/Sources/Internal/SwipeActionsView.swift, reading SwipeActionsView initialization and its state handling, then compare it with the accessibilityCustomActions setup in ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift. Run the existing test suite, then verify with VoiceOver that closed swipe buttons are absent from navigation while the list item's custom actions still appear and activate correctly.

Written by the indexing model from the issue text.

Assessment

Tech stack
swift
Domain
accessibility, mobile
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
82/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.