Skip to content

VoiceOver accessibility improvements for the clipboard history panel - #1448

Open
joelnicolastorello-lgtm wants to merge 6 commits into
p0deje:masterfrom
joelnicolastorello-lgtm:voiceover-accessibility-improvements
Open

VoiceOver accessibility improvements for the clipboard history panel#1448
joelnicolastorello-lgtm wants to merge 6 commits into
p0deje:masterfrom
joelnicolastorello-lgtm:voiceover-accessibility-improvements

Conversation

@joelnicolastorello-lgtm

Copy link
Copy Markdown

VoiceOver accessibility improvements for the clipboard history panel

A bit about me

I'm a junior developer taking some of my first real steps into open source — and into
accessibility work specifically. I lost my sight in 2020, and VoiceOver has been how
I've done everything on a Mac since, including, now, contributing code. This is one of
the first accessibility PRs I've ever attempted on GitHub, so please be gentle 🙂

Why this PR

Maccy is genuinely great, and it's an app I use constantly. But using it with VoiceOver
today ranges from "confusing" to "basically not possible" in a few key spots: there's no
way to tell which history item is selected, several buttons just announce as generic
system icon names with no indication of what they actually do, and arrow-key navigation
through your clipboard history is completely silent.

I'll be honest about the scope of who this matters to: this PR is essentially invisible
to 99% of the people who'll ever look at this repo. But for the 100% of people who
navigate macOS with a screen reader, it's the difference between being able to use Maccy
at all, or not.

What this changes

  • History rows are now real accessible elements, not just painted highlights. Each
    row exposes its full content (title, source app, pinned status), a proper button
    trait, and two actions — Pin/Unpin and Delete — reachable directly from VoiceOver.
  • Arrow-key navigation now announces the selected item (title, app it was copied
    from, and whether it's pinned). Before this, moving through your history with the
    keyboard produced no feedback at all.
  • The quick-action toolbar (Pin, Delete, copy extracted text, remove from paste
    stack) now has real labels.
    These were announcing as raw system icon names (e.g.
    the pin icon just read as "pin marker") instead of saying what the button does —
    something I only caught by actually using it live with VoiceOver, not by reading
    the code.
  • The status bar icon now has a label ("Maccy — clipboard history"), so it's
    identifiable when scanning the menu bar instead of being an anonymous icon.
  • Search field: the "clear search" button now has a label, and the decorative
    magnifying glass icon is hidden from the accessibility tree instead of being read
    as clutter.
  • The preview toggle button now announces "Show preview" / "Hide preview"
    depending on its current state, instead of nothing.
  • The footer's Clear / Clear all buttons are now properly hidden from
    accessibility when visually hidden — previously both existed in the accessibility
    tree simultaneously (one invisible), which meant a screen reader user could
    accidentally trigger "Clear all" while intending to trigger "Clear."
  • 8 settings controls (sliders, pickers, and keyboard shortcut recorders) that
    had no accessible label — VoiceOver would announce just the raw value with zero
    context about what setting it belonged to.
  • Spanish translations for every new accessibility string, alongside the English
    originals.

How I tested it

I didn't just read the source and guess. I built a debug version of the app and
tested every one of these changes live, with real VoiceOver, on my own Mac — copying
text, navigating history, pinning items, opening Settings, all of it. A few of the
bugs above (especially the toolbar icon-labeling one) only became obvious once I was
actually navigating with a screen reader instead of reading Swift — the kind of thing
that's genuinely hard to catch from code review alone.

What's not in this PR

There's an open question I looked into but couldn't responsibly resolve here: whether
VoiceOver's cursor reliably reaches the history popup at all when it opens (it's a
non-activating panel, which is exactly the reason it doesn't steal focus from whatever
app you're about to paste into). I found and reverted an attempted fix for this after
realizing it would silently break pasting into the wrong app — not something I was
willing to ship. My own live test suggests VoiceOver does reach the panel in
practice, but I'd rather flag this as unresolved than claim more confidence than I
actually have.

Being upfront

I know a PR like this, from someone this new both to the project and to accessibility
work in general, most likely won't get merged exactly as-is — maybe some of these
choices don't match how you'd want to approach it, maybe the scope should be split up,
maybe there's context about the codebase I'm missing. All of that's completely fine.
I mostly just wanted Maccy — an app I already rely on — to be a little more usable for
people like me.

Thank you, genuinely, for taking the time to read this far. 🙏

@joelnicolastorello-lgtm
joelnicolastorello-lgtm force-pushed the voiceover-accessibility-improvements branch from 7cdb6b9 to a836b2b Compare July 9, 2026 14:34

@weisJ weisJ left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for stepping up to implement this. I left few comments which should make the implementation cleaner and also make it easier to keep new code accessible.

Comment thread Maccy/Observables/NavigationManager.swift
Comment thread Maccy/Views/ConfirmationView.swift Outdated
Comment thread Maccy/Views/FooterView.swift Outdated
Comment thread Maccy/Views/HeaderView.swift Outdated
Comment thread Maccy/Views/ListItemView.swift Outdated
Comment thread Maccy/Views/ToolbarView.swift Outdated
Comment thread Maccy/FloatingPanel.swift Outdated
@joelnicolastorello-lgtm

Copy link
Copy Markdown
Author

Thank you so much for the detailed review — this is genuinely useful feedback and I appreciate you taking the time. I've pushed a commit addressing all six points:

  1. announceForAccessibility now takes the announcement as an @autoclosure, so the string-building work is skipped entirely when VoiceOver isn't running.
  2. Extracted buttonAction (in View+ButtonAction.swift): marks a view as a button and wires the same closure to both onTapGesture and accessibilityAction. Used in ConfirmationView and ListItemView in place of the duplicated pattern.
  3. Extracted invisible (in View+Invisible.swift): combines opacity + accessibilityHidden. Converted FooterView's clearOpacity/clearAllOpacity to booleans (showClear/showClearAll) as you suggested — turns out the crossfade wasn't actually animated to begin with, so this was a clean swap.
  4. KeyboardShortcutHelpModifier now also sets accessibilityLabel from the same resolved string used for .help(). Split PreviewKey into OpenPreview/ClosePreview in the PreviewItemView table so the label reflects the current toggle state. This replaced my one-off accessibilityLabel calls on Pin/Delete/preview-toggle.
  5. Extracted selectionNumber as a computed property, now shared between the visible badge and accessibilityValue.
  6. shortcutKeyHelp's name is now optional (defaults to nil), so CopyExtractedText — which has no keyboard shortcut — goes through the same modifier instead of a separate .help() + .accessibilityLabel() pair.

On the FloatingPanel.swift question: sorry for the confusion — there was nothing to revert in this PR's actual history. I tried NSApp.activate() locally while investigating whether VoiceOver reliably reaches the popup, realized it would silently break pasting into the wrong app, and reverted it before ever committing that version. The comment was describing that investigation, not undoing anything visible in this diff. I rewrote it to make that clear.

Let me know if any of these choices don't fit how you'd rather see it done — happy to adjust.

@weisJ

weisJ commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

On the FloatingPanel.swift question: sorry for the confusion — there was nothing to revert in this PR's actual history. I tried NSApp.activate() locally while investigating whether VoiceOver reliably reaches the popup, realized it would silently break pasting into the wrong app, and reverted it before ever committing that version. The comment was describing that investigation, not undoing anything visible in this diff. I rewrote it to make that clear.

You mentioned that voice over seems to reach the popup for you. Is that reliable? If yes I think we can leave the version as is. Otherwise I think we could incorporate a compatibility mode for VoiceOver. I will also test this locally so see whether I can reproduce a possible issue here.

@joelnicolastorello-lgtm

Copy link
Copy Markdown
Author

On reliability: so far it's been consistent for me. I've tested opening the popup multiple times in a row, both via the keyboard shortcut and by clicking the menu bar icon, and VoiceOver has reached it every time — I haven't hit a silent/unreachable case yet. I've also just been using Maccy day-to-day since testing this PR (it's genuinely become one of my favorite tools), so this isn't a one-off test, it's real daily use. That said, I don't want to overstate my confidence from a sample size of one person on one machine — really glad to hear you're going to try reproducing it locally too, that's a much better way to settle it than my testing alone.

And separately from the technical question — thank you, genuinely. This is one of the first proposals I've ever put up on GitHub, as a junior developer just starting to find his footing in open source contributions. Getting a review this thoughtful, from someone who clearly cares about getting accessibility right rather than just waving a PR through, means a lot more than I think you'd expect. Thank you for the corrections you suggested earlier too, and please forgive whatever mistakes I made along the way — I'm still learning how all of this works.

@weisJ
weisJ force-pushed the voiceover-accessibility-improvements branch 2 times, most recently from 90bf337 to b649bab Compare July 31, 2026 18:26
joelnicolastorello-lgtm and others added 6 commits July 31, 2026 21:07
- Expose history rows as real accessible elements (label, button trait,
  Pin/Unpin and Delete actions) instead of just a painted highlight
- Announce the selected item (title, source app, pinned state) on
  arrow-key navigation, which was previously silent
- Label the quick-action toolbar buttons (Pin, Delete, copy extracted
  text, remove from paste stack), which announced as raw system icon
  names with no indication of what they do
- Label the status bar icon, the search field's clear button, and the
  preview toggle (with its current state)
- Fix Clear/Clear all both existing in the accessibility tree at once
  when only one is visually shown
- Label 8 settings controls that had no accessible label at all
- Add Spanish translations for every new accessibility string

Tested live with VoiceOver, not just by reading the code.
- announceForAccessibility now takes the announcement as an
  autoclosure, so callers building a non-trivial string (e.g.
  NavigationManager's selection announcement) don't pay for that work
  when VoiceOver isn't running
- Extract the repeated "mark as button + wire the same closure to
  onTapGesture and accessibilityAction" pattern into a View.buttonAction
  extension, used by ConfirmationView and ListItemView
- Extract the repeated "opacity + accessibilityHidden" pattern into a
  View.invisible extension; convert FooterView's clearOpacity/
  clearAllOpacity to booleans (showClear/showClearAll) since the
  crossfade was never actually animated
- Extend KeyboardShortcutHelpModifier to also set accessibilityLabel
  from the same resolved string used for the .help() tooltip, and let
  its `name` parameter be nil for buttons with no keyboard shortcut
  (CopyExtractedText). This replaces the one-off accessibilityLabel
  calls on the Pin/Delete/CopyExtractedText toolbar buttons and on the
  preview toggle, whose PreviewKey string is now split into
  OpenPreview/ClosePreview so the label reflects the current state
- Extract the repeated "\(selectionIndex + 1)" into a computed
  property (selectionNumber) shared by the visible badge and
  accessibilityValue
- Rewrite the FloatingPanel.swift comment to clarify it documents an
  investigated-and-rejected local change, not a revert of anything
  that was ever committed to this PR
@weisJ
weisJ force-pushed the voiceover-accessibility-improvements branch from b649bab to 7886df1 Compare July 31, 2026 19:24

@p0deje p0deje left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First of all, let me thank you for taking the time and effort to work on this. This is a non-trivial piece of work, and I don't know how much time you spent on it. I'll do my best to make sure it's not in vain.

I've left a couple of minor comments, but once they are addressed, I'll pull this and give it a short test. I honestly don't know how to test the accessibility aspects, so I'll take your word for it, as you clearly have more experience in this area. From a code perspective, all changes make perfect sense. I just want to make sure nothing breaks with them.

On a side note, it would be great if there were a way to make sure accessibility is not an afterthought. I, for instance, will likely forget to add an accessibility label to a new UI element, so a linter or a shared view helper that propagates a visible text label to an accessibility label would be handy. I'm not sure it exists, and I think this PR should be merged without this, but I wish it were harder to miss these aspects.

Comment on lines +40 to +59
// Describe the complete item independently of its potentially truncated visual content.
private var accessibilityLabel: String {
var parts: [String] = []
if item.hasImage, let image = item.item.image {
let size = image.pixelSize
parts.append(String(format: NSLocalizedString("history_item_image_accessibility_label_no_app", comment: ""), Int(size.width), Int(size.height)))
} else {
parts.append(item.title)
}
if let application = item.application {
parts.append(application)
}
if item.isPinned {
parts.append(NSLocalizedString("history_item_pinned_accessibility_value", comment: ""))
}
if let index = visualIndex {
parts.append(String(format: NSLocalizedString("history_item_selected_accessibility_value", comment: ""), index + 1, appState.navigator.selection.count))
}
return parts.joined(separator: ", ")
}

@p0deje p0deje Aug 2, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to move this to a HistoryItemDecorator, so the view doesn't have to work both with item and item.item.


// Use the same selection number for the visible badge and accessibility value.
private var selectionNumber: String? {
selectionIndex.map { "\($0 + 1)" }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selectionIndex is an integer and I don't think the map function exists on the integers.

Comment thread Maccy/FloatingPanel.swift
let finalHeight = max(min(height, size.height), miniumHeight)
setContentSize(NSSize(width: finalWidth, height: finalHeight))
setFrameOrigin(popupPosition.origin(size: frame.size, statusBarButton: statusBarButton))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, remove as it's not needed.

Comment thread Maccy/VoiceOver.swift

/// Posts an announcement when VoiceOver is enabled.
///
/// The autoclosure avoids constructing announcements when they cannot be heard.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is not valid anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants