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:
- ✅
accessibilityCustomActions on the ContentViewContainer — works correctly, proper VoiceOver pattern
- ❌ 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
-
Existing tests should still pass — this change only affects accessibility tree visibility, not layout or behavior.
-
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
-
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.
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
DefaultSwipeActionButtoninstances 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:
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, theSwipeActionsViewframe is set to zero width viabounds.divided(atDistance: 0, from: .maxXEdge).sliceinItemCell.ContentViewContainer.updateFrames(). The view is visually clipped, but its childDefaultSwipeActionButtoninstances remain as accessibility elements.DefaultSwipeActionButton(line ~258 inListableUI/Sources/Internal/SwipeActionsView.swift) is aUIButtonsubclass. UIButton isisAccessibilityElement = trueby 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(inListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift) already setsaccessibilityCustomActionson the container from the swipe actions (line ~325). These custom actions work correctly — they callperformAccessibilityAction(_:)which triggers the swipe action'sonTaphandler and returnstrue. This is the proper VoiceOver interaction path.So there are two competing accessibility paths:
accessibilityCustomActionson theContentViewContainer— works correctly, proper VoiceOver patternDefaultSwipeActionButtoninstances in the view hierarchy — always visible to VoiceOver even when off-screen, activation failsFix
Hide the
SwipeActionsViewfrom the accessibility tree so VoiceOver only uses the custom actions path. The simplest fix:Option A: Hide SwipeActionsView from accessibility (recommended)
In
SwipeActionsView.swift, setaccessibilityElementsHidden = trueon theSwipeActionsViewin its initializer:This permanently hides all child buttons from VoiceOver, relying entirely on the
accessibilityCustomActionsset byContentViewContainer. This is the cleanest fix because: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
accessibilityElementsHiddenbased on swipe state:In
SwipeActionsView.apply(state:)(line ~226):And set the initial value in
init:Relevant Files
All paths are relative to the Listable repo root:
ListableUI/Sources/Internal/SwipeActionsView.swiftSwipeActionsViewclass (line ~12) andDefaultSwipeActionButtonclass (line ~258). The buttons are created here and never hidden from accessibility. This is where the fix goes.ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swiftContentViewContainermanages swipe registration, positioning, and accessibility custom actions. Lines ~318-328 set upaccessibilityCustomActionsfrom swipe actions — this already works correctly and is the proper VoiceOver path.How to Verify
Existing tests should still pass — this change only affects accessibility tree visibility, not layout or behavior.
Manual VoiceOver test:
Verify custom actions still work:
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.